From 0bfe9eea5f094462726d12bde5f99453b8458fd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:44:18 +0400 Subject: [PATCH] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B7=D0=BD=D0=B0=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?Q1=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=9E=D0=A1=D0=92,=20=D0=91=D0=91/=D0=9E=D0=A4?= =?UTF-8?q?=D0=A0=20=D0=B7=D0=B0=20Q1=20=D0=BA=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=20Q2=E2=80=93Q4=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20(R-14a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ББ/ОФР за Q1 — отчёт на конец квартала, не источник версий Q2–Q4. В `consolidation_documents._cumulative` равнозначность Q1 (`effective_cumulative`) применяется только к `document_type = OSV`; для ББ/ОФР — прежнее правило D-5 (NULL — нарастающий, только квартал версии), ключ замены не меняется. - тесты: ББ/ОФР за Q1 подходят только версии Q1; ключ замены ББ/ОФР всегда с признаком нарастающего, ОСВ за Q1 — без различия флага; подтверждение ББ/ОФР за Q1 при версии Q3 — без привязки и перехода; ячейка ББ чек-листа Q3 не закрывается ББ Q1 - спека documents-api: ключ замены R-1 с признаком нарастающего (вместо устаревшей «тройки»), равнозначность Q1 — только для ОСВ; consolidated-osv — «ОСВ за Q1» - design #81: R-14 + R-14a Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/consolidation_documents.py | 22 ++++--- api/tests/test_consolidation_documents.py | 63 +++++++++++++++++-- api/tests/test_domain_checklist.py | 9 +++ .../changes/slice-4-consolidation/design.md | 2 +- .../specs/consolidated-osv/spec.md | 2 +- .../specs/documents-api/spec.md | 9 +-- 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index f6bad2e..51098b0 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -8,9 +8,10 @@ (`unbind_entity`). Версии вне «в работе» не трогаются. Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` -привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР — -нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`: -документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний. +привязан один документ — с самым поздним `confirmed_at`; `cumulative = _cumulative(document)` (NULL — ББ/ОФР — +нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме ОСВ за +`Q1`: она равнозначна обоим видам (R-14), нарастающая и квартальная `Q1` — один слот, побеждает поздняя; ББ/ОФР +равнозначность не касается (R-14a). Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` (`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — @@ -28,7 +29,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, effective_cumulative +from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative, 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 @@ -57,9 +58,12 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` — - NULL нарастающий; документ за `Q1` — квартальный при любом флаге.""" - return effective_cumulative(document.is_cumulative, document.period_quarter) + """Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий; + ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР + за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие).""" + if document.document_type == DocumentType.OSV: + return effective_cumulative(document.is_cumulative, document.period_quarter) + return counts_as_cumulative(document.is_cumulative) def _key(document: Document) -> BindingKey: @@ -86,8 +90,8 @@ def fits( member_ids: Collection[uuid.UUID], ) -> bool: """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или - NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом - флаге (R-14). Статус документа и файла не проверяется — это + NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге + (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это дело вызывающего (привязываются только `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 3ec0e19..cde20a7 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -76,12 +76,17 @@ _STRANGER = uuid.uuid4() def _pure_document( - *, entity: uuid.UUID = _MEMBER, year: int = YEAR, quarter: PeriodQuarter | None = Q3, cumulative: bool | None + *, + entity: uuid.UUID = _MEMBER, + year: int = YEAR, + quarter: PeriodQuarter | None = Q3, + cumulative: bool | None, + kind: DocumentType = DocumentType.OSV, ) -> Document: return Document( legal_entity_id=entity, - document_type=DocumentType.OSV, - osv_account_code=ACCOUNT, + document_type=kind, + osv_account_code=ACCOUNT if kind == DocumentType.OSV else None, period_year=year, period_quarter=quarter, is_cumulative=cumulative, @@ -207,13 +212,43 @@ async def _bind(session: AsyncSession, version: ConsolidationVersion, document: @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 равнозначен нарастающему и квартальному — подходит любой версии года.""" + """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 +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +@pytest.mark.parametrize(("version_quarter", "expected"), [(Q1, True), (Q2, False), (Q3, False), (Q4, False)]) +def test_balance_and_income_of_q1_fit_only_q1_version( + kind: DocumentType, version_quarter: PeriodQuarter, expected: bool +) -> None: + """R-14a: ББ/ОФР за Q1 — отчёт на конец Q1, не источник версий Q2–Q4 (NULL — нарастающий, D-5 без изменений).""" + version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) + consolidation = Consolidation(year=YEAR, quarter=version_quarter) + document = _pure_document(cumulative=None, quarter=Q1, kind=kind) + + assert fits(document, version, consolidation, {_MEMBER}) is expected + + +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +@pytest.mark.parametrize("quarter", [Q1, Q2, Q3, Q4]) +def test_binding_key_of_balance_and_income_is_cumulative(kind: DocumentType, quarter: PeriodQuarter) -> None: + """R-14a: равнозначность Q1 ключ замены ББ/ОФР не меняет: признак нарастающего у них всегда `True` (№10).""" + document = _pure_document(cumulative=None, quarter=quarter, kind=kind) + + assert consolidation_documents._key(document)[-1] is True # noqa: SLF001 - ключ замены R-1 — внутренний + + +@pytest.mark.parametrize("cumulative", [True, False]) +def test_binding_key_of_osv_q1_ignores_flag(cumulative: bool) -> None: + """R-14: нарастающая и квартальная ОСВ за Q1 одной пары — один слот привязки.""" + document = _pure_document(cumulative=cumulative, quarter=Q1) + + assert consolidation_documents._key(document)[-1] is False # noqa: SLF001 - ключ замены R-1 — внутренний + + # --- подтверждение ---------------------------------------------------------------------------------------------------- @@ -284,6 +319,26 @@ async def test_confirm_binds_cumulative_q1_to_version_q3(seeded: AsyncSession, a assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION +@pytest.mark.integration +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +async def test_confirm_does_not_bind_balance_or_income_of_q1_to_version_q3( + seeded: AsyncSession, auth_client: AuthClientFactory, kind: DocumentType +) -> None: + """R-14a: ББ/ОФР за Q1 к версии Q3 не привязываются и статус версии не двигают.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0], quarter=Q1) + document.document_type, document.osv_account_code, document.is_cumulative = kind, None, None + await seeded.flush() + 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) == set() + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + + @pytest.mark.integration @pytest.mark.parametrize("bound_cumulative", [False, True]) async def test_confirm_q1_replaces_bound_q1_of_other_flag( diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py index bbd4913..ff4cd93 100644 --- a/api/tests/test_domain_checklist.py +++ b/api/tests/test_domain_checklist.py @@ -226,6 +226,15 @@ def test_balance_of_q2_at_version_q3_is_missing() -> None: assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) +@pytest.mark.parametrize("cumulative", [None, True, False]) +def test_balance_of_q1_at_version_q3_is_missing(cumulative: bool | None) -> None: + """R-14a: равнозначность Q1 касается только ОСВ — ББ за Q1 ячейку ББ версии Q3 не закрывает.""" + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q1, cumulative=cumulative) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) + + def test_documents_of_other_entity_are_ignored() -> None: entity, stranger = _entity(), _entity() foreign = _doc(stranger, quarter=Q3, cumulative=True) diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index db5af43..adf0362 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -265,4 +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` | +| 81 | 2026-10-06 | R-14 + R-14a: ОСВ за `Q1` (`period_quarter = Q1`) равнозначна и нарастающей, и квартальной. `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `select_sources` (для версии `Q2+` ОСВ за `Q1` — квартальный источник `Q1`; нарастающая за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающая «как есть», квартальная — склейка, №50), в адаптере чек-листа и — только для `document_type = OSV` — в `consolidation_documents._cumulative`, то есть в `fits` (ОСВ за `Q1` подходит любой версии года при любом флаге) и в ключе замены `_key` (нарастающая и квартальная ОСВ за `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`). R-14a: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4`: для них прежнее правило D-5 (NULL → нарастающий → только квартал версии), ключ замены не меняется | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающая и квартальная ОСВ за `Q1` одной пары вытесняют друг друга при привязке; если и ББ/ОФР за `Q1` должны подхватываться версиями `Q2`–`Q4` — снять условие `document_type = OSV` в `_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 2b5bd01..88e67de 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -13,7 +13,7 @@ привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` -документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть +ОСВ за `Q1` с любым `is_cumulative` служит квартальной `Q1` (нарастающий с начала года за первый квартал и есть квартал `Q1`, R-14); при наличии и нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки 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 90af9ee..a72f61d 100644 --- a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -12,10 +12,11 @@ В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, `period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал -версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14): -он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) — -одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой -(`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться.