fix(consolidation): документ за Q1 равнозначен нарастающему и квартальному (R-14)

Сквозной тест на фикстурах РБ показал: парсер помечает ОСВ за Q1 нарастающей, а
привязка (`fits`) брала нарастающий только за квартал версии, `select_sources` не
считал его квартальным Q1 — для версии Q3 привязано 17 из 22, пары 60/62(/76) в
пробеле [Q1], свод пуст.

- consolidation_periods.effective_cumulative: для Q1 — квартальный при любом флаге;
  одна функция для `fits`, ключа замены `_key`, `select_sources` (версии Q2+) и
  адаптера чек-листа; для версии Q1 поведение прежнее (№50)
- тесты: таблица effective_cumulative, select_sources/build/чек-лист с нарастающим
  Q1, fits любой версии года, подтверждение и подхват (нарастающий и квартальный Q1 —
  один слот, побеждает поздний)
- test_consolidation_fixtures: xfail снят, сценарий зелёный (22 привязки, 1385 строк)
- спеки documents-api и consolidated-osv: фраза о равнозначности Q1; design #81;
  план Task 13 Step 2–4 и tasks.md 5.1 отмечены

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:36:24 +04:00
parent af91643811
commit d3c4964e92
13 changed files with 220 additions and 32 deletions

View File

@ -23,7 +23,7 @@ from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol, TypeVar 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.domain.osv_mapping import account_sort_key
from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter
@ -130,14 +130,18 @@ def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLi
Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле
`Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке
кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46). кварталов; документ за `Q1` — квартальный при любом флаге, R-14) и перечисляет `missing_quarters` только при
частичном покрытии: без единого документа — `()` (№46).
""" """
selected = select_sources(version_quarter, documents) selected = select_sources(version_quarter, documents)
if isinstance(selected, Sources): if isinstance(selected, Sources):
return _Coverage(True, (), tuple(d.id for d in selected.documents)) return _Coverage(True, (), tuple(d.id for d in selected.documents))
needed = quarters_through(version_quarter) needed = quarters_through(version_quarter)
present = tuple( 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) return _Coverage(False, selected.missing_quarters if present else (), present)

View File

@ -19,6 +19,7 @@ __all__ = [
"Gap", "Gap",
"Sources", "Sources",
"counts_as_cumulative", "counts_as_cumulative",
"effective_cumulative",
"quarters_through", "quarters_through",
"reporting_date", "reporting_date",
"select_sources", "select_sources",
@ -56,6 +57,17 @@ def counts_as_cumulative(is_cumulative: bool | None) -> bool:
return is_cumulative is not False 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): class DocLike(Protocol):
"""Документ-кандидат в источники (структурный тип): поля — как в DBML `document`.""" """Документ-кандидат в источники (структурный тип): поля — как в DBML `document`."""
@ -90,10 +102,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So
"""Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03). """Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03).
1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные 1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные
рядом игнорируются (нарастающий полнее по определению). рядом игнорируются (нарастающий полнее по определению). Для версии `Q1` это нарастающий `Q1` «как есть».
2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный 2. Иначе нужны квартальные за каждый квартал `Q1..Qn` версии (`effective_cumulative` ложен: `is_cumulative = False`
`Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала — или любой документ за `Q1` — R-14, нарастающий `Q1` для версий `Q2+` служит квартальным `Q1`); для версии `Q1`
порядок входа). квартальный `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри
квартала — порядок входа).
3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`. 3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`.
Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие
@ -106,7 +119,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So
if cumulative: if cumulative:
return Sources(documents=cumulative, cumulative=True) return Sources(documents=cumulative, cumulative=True)
needed = quarters_through(version_quarter) 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)) missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly))
if missing: if missing:
return Gap(missing_quarters=missing) return Gap(missing_quarters=missing)

View File

@ -8,8 +8,9 @@
(`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 = is_cumulative is not False` (NULL — ББ/ОФР — привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР —
нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют. нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`:
документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний.
Порядок блокировок (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 ГК → версии; создание версии —
@ -27,7 +28,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, counts_as_cumulative from src.domain.consolidation_periods import QUARTER_ORDER, 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
@ -56,8 +57,9 @@ def _rowcount(result: Any) -> int:
def _cumulative(document: Document) -> bool: def _cumulative(document: Document) -> bool:
"""Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий.""" """Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` —
return counts_as_cumulative(document.is_cumulative) NULL нарастающий; документ за `Q1` — квартальный при любом флаге."""
return effective_cumulative(document.is_cumulative, document.period_quarter)
def _key(document: Document) -> BindingKey: def _key(document: Document) -> BindingKey:
@ -84,7 +86,8 @@ def fits(
member_ids: Collection[uuid.UUID], member_ids: Collection[uuid.UUID],
) -> bool: ) -> bool:
"""Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или
NULL) — квартал версии; квартальный — не позже квартала версии. Статус документа и файла не проверяется — это NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом
флаге (R-14). Статус документа и файла не проверяется — это
дело вызывающего (привязываются только `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

@ -101,6 +101,10 @@ def _pure_document(
(_pure_document(cumulative=False, quarter=None), False), # квартал не определён (_pure_document(cumulative=False, quarter=None), False), # квартал не определён
(_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10) (_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10)
(_pure_document(cumulative=None, quarter=Q2), False), # NULL за Q2 при версии Q3 — как нарастающий (_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: def test_fits(document: Document, expected: bool) -> None:
@ -200,6 +204,16 @@ async def _bind(session: AsyncSession, version: ConsolidationVersion, document:
await session.flush() 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) 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 @pytest.mark.integration
async def test_confirm_does_not_bind_cumulative_of_earlier_quarter( async def test_confirm_does_not_bind_cumulative_of_earlier_quarter(
seeded: AsyncSession, auth_client: AuthClientFactory 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} 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 @pytest.mark.integration
async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None: async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None:
"""Решение №35: при равном `confirmed_at` привязывается документ с большим `id`.""" """Решение №35: при равном `confirmed_at` привязывается документ с большим `id`."""

View File

@ -101,16 +101,6 @@ async def _all_rows(client: AsyncClient, version_url: str, **params: Any) -> lis
return items 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 - сквозной сценарий в одном месте async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0914, PLR0915 - сквозной сценарий в одном месте
auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path
) -> None: ) -> None:

View File

@ -99,6 +99,25 @@ def test_cumulative_of_other_quarter_does_not_cover() -> None:
assert cell == Cell(CellStatus.MISSING) 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: def test_q1_version_quarterly_q1_equals_cumulative() -> None:
entity = _entity() entity = _entity()
document = _doc(entity, quarter=Q1, cumulative=False) document = _doc(entity, quarter=Q1, cumulative=False)

View File

@ -206,6 +206,30 @@ def test_cumulative_next_to_quarterly_rows_as_is() -> None:
assert result.gaps == () 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: def test_cumulative_document_keeps_duplicate_keys_separate() -> None:
"""Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6)."""
cumulative = _doc(cumulative=True) cumulative = _doc(cumulative=True)

View File

@ -11,6 +11,7 @@ from src.domain.consolidation_periods import (
Gap, Gap,
Sources, Sources,
counts_as_cumulative, counts_as_cumulative,
effective_cumulative,
quarters_through, quarters_through,
reporting_date, reporting_date,
select_sources, 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: def test_no_documents_is_gap_of_all_quarters() -> None:
assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3)) assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3))
assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,)) 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)

View File

@ -253,8 +253,8 @@
- Время теста ≤ 60 с; выводить в лог число строк свода. - Время теста ≤ 60 с; выводить в лог число строк свода.
- [x] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). - [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 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 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления; закрыты исправлением R-14 — документ за Q1 равнозначен нарастающему и квартальному, xfail снят).
--- ---

View File

@ -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 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 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` |

View File

@ -12,7 +12,9 @@
Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов, Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов,
привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии
→ единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала
версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; при наличии и версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4`
документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть
квартал `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). Квартальные строки
SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из

View File

@ -12,7 +12,9 @@
В той же транзакции документ 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` ≤ квартала версии). Привязанный ранее документ того же ЮЛ с той же тройкой версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14):
он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) —
одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой
(`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая (`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая
привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` =
подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться.

View File

@ -56,11 +56,11 @@
## 5. Сквозной тест и документы ## 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 у Счастливочной, Счастливочной за Q1–Q3 2024 → подтверждение → консолидация 2024 Q3 → свод: счета 60/62 у обеих и 76 у Счастливочной,
остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем; остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем;
экспорт открывается. НЕ закрыт: тест написан и помечен `xfail(strict)` — на реальных данных нарастающий Q1 экспорт открывается. Найденный тестом дефект (нарастающий Q1 не привязывался к версии Q3 и не считался
(`is_cumulative = true`) не привязывается к версии Q3 (`fits`) и не считается квартальным Q1 (`select_sources`); квартальным Q1) исправлен рулингом R-14 (design #81): документ за Q1 равнозначен нарастающему и квартальному;
снять xfail после исправления. xfail снят.
- [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md - [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md
(новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9). (новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9).