fix(consolidation): равнозначность Q1 — только для ОСВ, ББ/ОФР за Q1 к версиям Q2–Q4 не привязываются (R-14a)

ББ/ОФР за 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 07:44:18 +04:00
parent d3c4964e92
commit 0bfe9eea5f
6 changed files with 88 additions and 19 deletions

View File

@ -8,9 +8,10 @@
(`unbind_entity`). Версии вне «в работе» не трогаются. (`unbind_entity`). Версии вне «в работе» не трогаются.
Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)`
привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР — привязан один документ — с самым поздним `confirmed_at`; `cumulative = _cumulative(document)` (NULL — ББ/ОФР —
нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`: нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме ОСВ за
документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний. `Q1`: она равнозначна обоим видам (R-14), нарастающая и квартальная `Q1` — один слот, побеждает поздняя; ББ/ОФР
равнозначность не касается (R-14a).
Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id`
(`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — (`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии —
@ -28,7 +29,7 @@ from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession 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.domain.policy import IN_WORK_STATUSES
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
@ -57,9 +58,12 @@ def _rowcount(result: Any) -> int:
def _cumulative(document: Document) -> bool: def _cumulative(document: Document) -> bool:
"""Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` — """Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий;
NULL нарастающий; документ за `Q1` — квартальный при любом флаге.""" ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР
return effective_cumulative(document.is_cumulative, document.period_quarter) за `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: def _key(document: Document) -> BindingKey:
@ -86,8 +90,8 @@ def fits(
member_ids: Collection[uuid.UUID], member_ids: Collection[uuid.UUID],
) -> bool: ) -> bool:
"""Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или
NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге
флаге (R-14). Статус документа и файла не проверяется — это (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это
дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД."""
if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: if document.legal_entity_id not in member_ids or document.period_year != consolidation.year:
return False return False

View File

@ -76,12 +76,17 @@ _STRANGER = uuid.uuid4()
def _pure_document( 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: ) -> Document:
return Document( return Document(
legal_entity_id=entity, legal_entity_id=entity,
document_type=DocumentType.OSV, document_type=kind,
osv_account_code=ACCOUNT, osv_account_code=ACCOUNT if kind == DocumentType.OSV else None,
period_year=year, period_year=year,
period_quarter=quarter, period_quarter=quarter,
is_cumulative=cumulative, 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("version_quarter", [Q1, Q2, Q3, Q4])
@pytest.mark.parametrize("cumulative", [True, False, None]) @pytest.mark.parametrize("cumulative", [True, False, None])
def test_q1_document_fits_every_version_of_year(version_quarter: PeriodQuarter, cumulative: bool | None) -> 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) version = ConsolidationVersion(status=ConsolidationStatus.INITIATED)
consolidation = Consolidation(year=YEAR, quarter=version_quarter) consolidation = Consolidation(year=YEAR, quarter=version_quarter)
assert fits(_pure_document(cumulative=cumulative, quarter=Q1), version, consolidation, {_MEMBER}) is True 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 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.integration
@pytest.mark.parametrize("bound_cumulative", [False, True]) @pytest.mark.parametrize("bound_cumulative", [False, True])
async def test_confirm_q1_replaces_bound_q1_of_other_flag( async def test_confirm_q1_replaces_bound_q1_of_other_flag(

View File

@ -226,6 +226,15 @@ def test_balance_of_q2_at_version_q3_is_missing() -> None:
assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) 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: def test_documents_of_other_entity_are_ignored() -> None:
entity, stranger = _entity(), _entity() entity, stranger = _entity(), _entity()
foreign = _doc(stranger, quarter=Q3, cumulative=True) foreign = _doc(stranger, quarter=Q3, cumulative=True)

View File

@ -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 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 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` | | 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 вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | | 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` |

View File

@ -13,7 +13,7 @@
привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии
→ единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала
версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4`
документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть ОСВ за `Q1` с любым `is_cumulative` служит квартальной `Q1` (нарастающий с начала года за первый квартал и есть
квартал `Q1`, R-14); при наличии и квартал `Q1`, R-14); при наличии и
нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с
`missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки

View File

@ -12,10 +12,11 @@
В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии,
`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал `period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал
версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14): версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она
он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) — подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии
одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`,
(`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая `period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же
версии SHALL отвязываться (R-1). Каждая версия, получившая
привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` =
подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться.