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 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)

View File

@ -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)

View File

@ -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

View File

@ -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`."""

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 снят).
---

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

View File

@ -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_*` из

View File

@ -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 требоваться.

View File

@ -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).