diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index 1cd24cf..7cfc91d 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -23,7 +23,7 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Protocol, TypeVar -from src.domain.consolidation_periods import Sources, quarters_through, select_sources +from src.domain.consolidation_periods import Sources, effective_cumulative, quarters_through, select_sources from src.domain.osv_mapping import account_sort_key from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -130,14 +130,18 @@ def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLi Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке - кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46). + кварталов; документ за `Q1` — квартальный при любом флаге, R-14) и перечисляет `missing_quarters` только при + частичном покрытии: без единого документа — `()` (№46). """ selected = select_sources(version_quarter, documents) if isinstance(selected, Sources): return _Coverage(True, (), tuple(d.id for d in selected.documents)) needed = quarters_through(version_quarter) present = tuple( - d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter + d.id + for quarter in needed + for d in documents + if not effective_cumulative(d.is_cumulative, d.period_quarter) and d.period_quarter == quarter ) return _Coverage(False, selected.missing_quarters if present else (), present) diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py index 4948bed..6faf2aa 100644 --- a/api/src/domain/consolidation_periods.py +++ b/api/src/domain/consolidation_periods.py @@ -19,6 +19,7 @@ __all__ = [ "Gap", "Sources", "counts_as_cumulative", + "effective_cumulative", "quarters_through", "reporting_date", "select_sources", @@ -56,6 +57,17 @@ def counts_as_cumulative(is_cumulative: bool | None) -> bool: return is_cumulative is not False +def effective_cumulative(is_cumulative: bool | None, period_quarter: PeriodQuarter | None) -> bool: + """Нарастающий ли документ для привязки, ключа замены и выбора квартальных источников (R-14). + + Документ за `Q1` равнозначен и нарастающему, и квартальному (нарастающий с начала года за первый квартал и есть + квартал `Q1`; парсер помечает такие ОСВ нарастающими): здесь он считается квартальным при любом флаге — подходит + любой версии года, а нарастающий и квартальный `Q1` одной пары — один слот привязки. Прочие — + `counts_as_cumulative`. + """ + return period_quarter != PeriodQuarter.Q1 and counts_as_cumulative(is_cumulative) + + class DocLike(Protocol): """Документ-кандидат в источники (структурный тип): поля — как в DBML `document`.""" @@ -90,10 +102,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So """Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03). 1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные - рядом игнорируются (нарастающий полнее по определению). - 2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный - `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала — - порядок входа). + рядом игнорируются (нарастающий полнее по определению). Для версии `Q1` это нарастающий `Q1` «как есть». + 2. Иначе нужны квартальные за каждый квартал `Q1..Qn` версии (`effective_cumulative` ложен: `is_cumulative = False` + или любой документ за `Q1` — R-14, нарастающий `Q1` для версий `Q2+` служит квартальным `Q1`); для версии `Q1` + квартальный `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри + квартала — порядок входа). 3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`. Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие @@ -106,7 +119,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So if cumulative: return Sources(documents=cumulative, cumulative=True) needed = quarters_through(version_quarter) - quarterly = [d for d in documents if d.is_cumulative is False and d.period_quarter in needed] + quarterly = [ + d + for d in documents + if not effective_cumulative(d.is_cumulative, d.period_quarter) and d.period_quarter in needed + ] missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly)) if missing: return Gap(missing_quarters=missing) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 301a110..f6bad2e 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -8,8 +8,9 @@ (`unbind_entity`). Версии вне «в работе» не трогаются. Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` -привязан один документ — с самым поздним `confirmed_at`; `cumulative = is_cumulative is not False` (NULL — ББ/ОФР — -нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют. +привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР — +нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`: +документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний. Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` (`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — @@ -27,7 +28,7 @@ from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession -from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative +from src.domain.consolidation_periods import QUARTER_ORDER, effective_cumulative from src.domain.policy import IN_WORK_STATUSES from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -56,8 +57,9 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий.""" - return counts_as_cumulative(document.is_cumulative) + """Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` — + NULL нарастающий; документ за `Q1` — квартальный при любом флаге.""" + return effective_cumulative(document.is_cumulative, document.period_quarter) def _key(document: Document) -> BindingKey: @@ -84,7 +86,8 @@ def fits( member_ids: Collection[uuid.UUID], ) -> bool: """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или - NULL) — квартал версии; квартальный — не позже квартала версии. Статус документа и файла не проверяется — это + NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом + флаге (R-14). Статус документа и файла не проверяется — это дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: return False diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index faa7ee5..3ec0e19 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -101,6 +101,10 @@ def _pure_document( (_pure_document(cumulative=False, quarter=None), False), # квартал не определён (_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10) (_pure_document(cumulative=None, quarter=Q2), False), # NULL за Q2 при версии Q3 — как нарастающий + (_pure_document(cumulative=True, quarter=Q1), True), # R-14: нарастающий Q1 = квартальный Q1, подходит Q3 + (_pure_document(cumulative=None, quarter=Q1), True), # R-14: флаг за Q1 не различает + (_pure_document(year=YEAR - 1, cumulative=True, quarter=Q1), False), # Q1 другого года + (_pure_document(entity=_STRANGER, cumulative=True, quarter=Q1), False), # Q1 не члена периметра ], ) def test_fits(document: Document, expected: bool) -> None: @@ -200,6 +204,16 @@ async def _bind(session: AsyncSession, version: ConsolidationVersion, document: await session.flush() +@pytest.mark.parametrize("version_quarter", [Q1, Q2, Q3, Q4]) +@pytest.mark.parametrize("cumulative", [True, False, None]) +def test_q1_document_fits_every_version_of_year(version_quarter: PeriodQuarter, cumulative: bool | None) -> None: + """R-14: документ за Q1 равнозначен нарастающему и квартальному — подходит любой версии года.""" + version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) + consolidation = Consolidation(year=YEAR, quarter=version_quarter) + + assert fits(_pure_document(cumulative=cumulative, quarter=Q1), version, consolidation, {_MEMBER}) is True + + # --- подтверждение ---------------------------------------------------------------------------------------------------- @@ -255,6 +269,41 @@ async def test_confirm_binds_in_other_in_work_statuses( assert len(await _history(seeded, version.id)) == before + (1 if moved else 0) +@pytest.mark.integration +async def test_confirm_binds_cumulative_q1_to_version_q3(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """R-14 (фикстуры РБ): нарастающая ОСВ за Q1 привязывается к версии Q3 как квартальный Q1 и двигает версию.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0], quarter=Q1, cumulative=True) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {document.id} + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +@pytest.mark.parametrize("bound_cumulative", [False, True]) +async def test_confirm_q1_replaces_bound_q1_of_other_flag( + seeded: AsyncSession, auth_client: AuthClientFactory, bound_cumulative: bool +) -> None: + """R-14: нарастающий и квартальный Q1 одной пары (ЮЛ, счёт) — один слот привязки: подтверждённый позже вытесняет + привязанный.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DATA_CORRECTION) + old = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=bound_cumulative) + await _bind(seeded, version, old) + new = await _document(seeded, entities[0], quarter=Q1, cumulative=not bound_cumulative) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{new.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {new.id} + + @pytest.mark.integration async def test_confirm_does_not_bind_cumulative_of_earlier_quarter( seeded: AsyncSession, auth_client: AuthClientFactory @@ -487,6 +536,32 @@ async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSe assert await _bound(seeded, version.id) == {newer.id} +@pytest.mark.integration +@pytest.mark.parametrize("later_cumulative", [True, False]) +async def test_bind_confirmed_q1_of_both_flags_is_one_slot(seeded: AsyncSession, later_cumulative: bool) -> None: + """R-14: подхват из нарастающего и квартального Q1 одной пары берёт один — с более поздним `confirmed_at`.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + now = datetime.datetime.now(datetime.UTC) + later = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=later_cumulative, confirmed_at=now) + await _confirmed( + seeded, + entities[0], + quarter=Q1, + cumulative=not later_cumulative, + confirmed_at=now - datetime.timedelta(hours=1), + ) + + count = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + + assert count == 1 + assert await _bound(seeded, version.id) == {later.id} + + @pytest.mark.integration async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None: """Решение №35: при равном `confirmed_at` привязывается документ с большим `id`.""" diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py index 26e677f..daede18 100644 --- a/api/tests/test_consolidation_fixtures.py +++ b/api/tests/test_consolidation_fixtures.py @@ -101,16 +101,6 @@ async def _all_rows(client: AsyncClient, version_url: str, **params: Any) -> lis return items -@pytest.mark.xfail( - strict=True, - reason=( - "ДЕФЕКТ Срезов 4 (D-5/D-2): golden ОСВ Q1 помечены is_cumulative=true (парсер: первый квартал нарастающий), а " - "`consolidation_documents.fits` привязывает нарастающий документ только за квартал версии, и " - "`consolidation_periods.select_sources` не считает нарастающий Q1 квартальным — для версии Q3 документы Q1 не " - "привязаны (17 из 22), пары 60/62/76 уходят в gaps [Q1], rows_total = 0. Снять xfail после исправления: " - "нарастающий Q1 равнозначен квартальному Q1 для любой версии года (fits + select_sources)" - ), -) async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0914, PLR0915 - сквозной сценарий в одном месте auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path ) -> None: diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py index 57c8ec6..bbd4913 100644 --- a/api/tests/test_domain_checklist.py +++ b/api/tests/test_domain_checklist.py @@ -99,6 +99,25 @@ def test_cumulative_of_other_quarter_does_not_cover() -> None: assert cell == Cell(CellStatus.MISSING) +def test_cumulative_q1_with_quarterly_q2_q3_confirms() -> None: + """R-14: нарастающий Q1 (так парсер помечает первый квартал) равнозначен квартальному Q1 для версии Q3.""" + entity = _entity() + documents = [_doc(entity, quarter=Q1, cumulative=True), _doc(entity, quarter=Q2), _doc(entity, quarter=Q3)] + + cell = _cell(OSV_60, entity, bound=documents) + + assert cell == Cell(CellStatus.CONFIRMED, (), tuple(d.id for d in documents)) + + +def test_cumulative_q1_counts_as_present_quarter_of_gap() -> None: + entity = _entity() + first, third = _doc(entity, quarter=Q1, cumulative=True), _doc(entity, quarter=Q3) + + cell = _cell(OSV_60, entity, bound=[third, first]) + + assert cell == Cell(CellStatus.MISSING, (Q2,), (first.id, third.id)) + + def test_q1_version_quarterly_q1_equals_cumulative() -> None: entity = _entity() document = _doc(entity, quarter=Q1, cumulative=False) diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index f2ed683..8e34e25 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -206,6 +206,30 @@ def test_cumulative_next_to_quarterly_rows_as_is() -> None: assert result.gaps == () +def test_cumulative_q1_with_quarterly_q2_q3_is_glued() -> None: + """R-14, форма фикстур РБ: ОСВ Q1 помечена нарастающей, Q2/Q3 — квартальные; версия Q3 — склейка: opening из Q1, + turnover = Σ, closing из Q3, строка квартальная; пробела нет.""" + q1, q2, q3 = _doc(quarter=Q1, cumulative=True), _doc(quarter=Q2), _doc(quarter=Q3) + r1 = _row(q1, od="100", oc="5", td="200", tc="7", cd="300", cc="12") + r2 = _row(q2, od="300", oc="12", td="150", tc=None, cd="450", cc="12") + r3 = _row(q3, od="450", oc="12", td="50", tc="3", cd="500", cc="15") + result = _build([q1, q2, q3], [r3, r1, r2]) + assert result.gaps == () + assert len(result.rows) == 1 + row = result.rows[0] + assert _sums(row) == (Decimal(100), Decimal(5), Decimal(400), Decimal(10), Decimal(500), Decimal(15)) + assert row.is_cumulative is False + assert row.entry_ids == (r1.id, r2.id, r3.id) + + +def test_cumulative_q1_at_version_q1_is_taken_as_is() -> None: + q1 = _doc(quarter=Q1, cumulative=True) + source = _row(q1, od="1", oc=None, td="2", tc=None, cd="3", cc=None) + row = _build([q1], [source], quarter=Q1).rows[0] + assert row.is_cumulative is True + assert _sums(row) == (Decimal(1), None, Decimal(2), None, Decimal(3), None) + + def test_cumulative_document_keeps_duplicate_keys_separate() -> None: """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" cumulative = _doc(cumulative=True) diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py index 501af6a..014040b 100644 --- a/api/tests/test_domain_consolidation_periods.py +++ b/api/tests/test_domain_consolidation_periods.py @@ -11,6 +11,7 @@ from src.domain.consolidation_periods import ( Gap, Sources, counts_as_cumulative, + effective_cumulative, quarters_through, reporting_date, select_sources, @@ -115,3 +116,53 @@ def test_quarterly_after_version_and_without_quarter_are_ignored() -> None: def test_no_documents_is_gap_of_all_quarters() -> None: assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3)) assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,)) + + +# ------------------------------------------------------------------------- R-14: документ за Q1 равнозначен обоим видам + + +@pytest.mark.parametrize( + ("flag", "quarter", "expected"), + [ + (True, Q1, False), # нарастающий с начала года за Q1 — это и есть квартал Q1 + (None, Q1, False), + (False, Q1, False), + (True, Q2, True), + (None, Q3, True), # ББ/ОФР вне Q1 — нарастающие (решение №10) + (False, Q3, False), + (True, None, True), # квартал не определён — по флагу (такой документ не привязывается) + ], +) +def test_effective_cumulative(flag: bool | None, quarter: PeriodQuarter | None, expected: bool) -> None: + assert effective_cumulative(flag, quarter) is expected + + +def test_cumulative_q1_is_quarterly_q1_source_for_later_version() -> None: + """Фикстуры РБ: парсер помечает ОСВ за Q1 нарастающей, Q2/Q3 — квартальными; для версии Q3 это полный комплект.""" + q1, q2, q3 = _doc(Q1, True), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q3, q2, q1]) == Sources(documents=(q1, q2, q3), cumulative=False) + assert select_sources(Q2, [q2, q1]) == Sources(documents=(q1, q2), cumulative=False) + + +def test_null_flag_q1_is_quarterly_q1_source_for_later_version() -> None: + q1, q2 = _doc(Q1, None), _doc(Q2) + assert select_sources(Q2, [q1, q2]) == Sources(documents=(q1, q2), cumulative=False) + + +def test_cumulative_q1_does_not_fill_other_quarters() -> None: + assert select_sources(Q2, [_doc(Q1, True)]) == Gap(missing_quarters=(Q2,)) + # нарастающий Q2 при версии Q3 — не квартальный Q2: не хватает квартальных Q2 и Q3 + assert select_sources(Q3, [_doc(Q1, True), _doc(Q2, True)]) == Gap(missing_quarters=(Q2, Q3)) + + +def test_cumulative_of_version_quarter_still_beats_cumulative_q1() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1, True), _doc(Q2), _doc(Q3), cumulative]) == Sources((cumulative,), True) + + +def test_q1_version_cumulative_q1_is_taken_as_is() -> None: + """Версия Q1: нарастающий Q1 — источник «как есть» (решение №50), квартальный — склейка из одного квартала.""" + cumulative, quarterly = _doc(Q1, True), _doc(Q1, False) + assert select_sources(Q1, [cumulative]) == Sources((cumulative,), cumulative=True) + assert select_sources(Q1, [quarterly, cumulative]) == Sources((cumulative,), cumulative=True) + assert select_sources(Q1, [quarterly]) == Sources((quarterly,), cumulative=False) diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index ad9e753..52c1d07 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -253,8 +253,8 @@ - Время теста ≤ 60 с; выводить в лог число строк свода. - [x] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). -- [ ] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). -- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления). +- [x] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). +- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления; закрыты исправлением R-14 — документ за Q1 равнозначен нарастающему и квартальному, xfail снят). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 44c748d..db5af43 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -265,3 +265,4 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | | 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из spec consolidated-osv «Сопоставление из плана среза» верно только для нарастающих документов (одна запись = одна строка свода) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | +| 81 | 2026-10-06 | R-14: документ за `Q1` (`period_quarter = Q1`) равнозначен и нарастающему, и квартальному. Одна функция `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `fits` (документ за `Q1` подходит любой версии года при любом флаге, прочие — как раньше), в ключе замены `_key` (нарастающий и квартальный `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`), в `select_sources` (для версии `Q2+` документ за `Q1` — квартальный источник `Q1`; нарастающий за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающий «как есть», квартальный — склейка, №50) и в адаптере чек-листа | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающий `Q1` и квартальный `Q1` одной пары вытесняют друг друга при привязке; правило распространяется и на ББ/ОФР за `Q1` (`is_cumulative` NULL): они тоже привязываются к версиям `Q2`–`Q4` и подтверждение такого документа двигает версию в `DATA_CORRECTION` (ячейки ББ/ОФР чек-листа не затронуты — они требуют квартала версии, №49); если владелец хочет иное — меняется только `effective_cumulative` | diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md index cc3545e..2b5bd01 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -12,7 +12,9 @@ Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов, привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала -версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; при наличии и +версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` +документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть +квартал `Q1`, R-14); при наличии и нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md index 5e2f6e4..90af9ee 100644 --- a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -12,7 +12,9 @@ В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, `period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал -версии; false → `period_quarter` ≤ квартала версии). Привязанный ранее документ того же ЮЛ с той же тройкой +версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14): +он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) — +одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой (`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index e1a2326..7559995 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -56,11 +56,11 @@ ## 5. Сквозной тест и документы -- [ ] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и +- [x] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и Счастливочной за Q1–Q3 2024 → подтверждение → консолидация 2024 Q3 → свод: счета 60/62 у обеих и 76 у Счастливочной, остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем; - экспорт открывается. НЕ закрыт: тест написан и помечен `xfail(strict)` — на реальных данных нарастающий Q1 - (`is_cumulative = true`) не привязывается к версии Q3 (`fits`) и не считается квартальным Q1 (`select_sources`); - снять xfail после исправления. + экспорт открывается. Найденный тестом дефект (нарастающий Q1 не привязывался к версии Q3 и не считался + квартальным Q1) исправлен рулингом R-14 (design #81): документ за Q1 равнозначен нарастающему и квартальному; + xfail снят. - [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md (новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9).