test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»

Хелпер map_golden_snapshot, сценарий маппинг golden -> подтверждение -> консолидация 2024 Q3 -> свод,
сопоставление Счастливочной, экспорт, независимая сверка склейки периодов. Тест помечен xfail(strict):
на реальных данных нарастающий Q1 (is_cumulative=true) не привязывается к версии Q3 и не считается
квартальным (fits / select_sources) — свод пуст; подробности в отчёте задачи.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 07:25:30 +04:00
parent 5b3b1011cb
commit af91643811
6 changed files with 364 additions and 11 deletions

View File

@ -1,7 +1,19 @@
"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`."""
import json
import os
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.contract.result import RecognitionResult
from src.models.directories import LegalEntity
from src.models.files import Document
FIXTURES_ENV = "ARM_KA_FIXTURES"
@ -26,3 +38,49 @@ def fixtures_skip_reason() -> str | None:
f"ARM_KA_FIXTURES недоступен: нет каталога {path} "
f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)"
)
def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult":
"""Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса."""
from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest
data = json.loads(path.read_text("utf-8"))
data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
async def map_golden_snapshot(
session: AsyncSession, *, entity: "LegalEntity", path: Path, uploaded_by: uuid.UUID | None
) -> "list[Document]":
"""Golden-снимок `path` → файл ЮЛ `entity` и его документы ОСВ в БД теста (`flush`, без commit).
Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и настоящим
`CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы до маппинга).
Возвращает документы файла (после автозамены прежних версий — только документы этого файла).
"""
# ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл)
from src.models.accounting import ChartOfAccount # noqa: PLC0415
from src.models.enums import FileStatus, RecognitionStatus # noqa: PLC0415
from src.models.files import Document # noqa: PLC0415
from src.repositories.counterparties import CounterpartyIndex # noqa: PLC0415
from src.services.result_mapping import map_file # noqa: PLC0415
from tests.factories import make_stored_file # noqa: PLC0415
file, job = await make_stored_file(
session,
entity=entity,
status=FileStatus.PROCESSING,
job_status=RecognitionStatus.done,
original_name=path.name,
uploaded_by=uploaded_by,
)
chart = {
code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
}
index = await CounterpartyIndex.load(session)
result = golden_result(path, job_id=job.id, file_id=file.id)
await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC))
documents = await session.execute(
select(Document).where(Document.stored_file_id == file.id).order_by(Document.osv_account_code)
)
return list(documents.scalars())

View File

@ -0,0 +1,297 @@
"""Сквозной сценарий Среза 4 на реальных данных группы «Русский бисквит» (tasks 5.1; spec consolidated-osv «Признаки на
фикстурах РБ», «Сопоставление из плана среза», «Рекомендуемая Счастливочная»; documents-api «Фикстуры, подтверждённые
до консолидации»).
Golden-снимки парсера `rb/2024-Q1..Q3` и `schastl/2024-Q1..Q3` → боевой `map_file` → подтверждение через
`POST /documents/{id}/confirm` → создание консолидации 2024 Q3 (подхват подтверждённых) → блокировка → сводная ОСВ,
сводка и пробелы, ручное сопоставление «Пекарни Счастливочной» с автоприменением → xlsx-экспорт. Суммы свода сверяются с
суммой квартальных значений, прочитанных из golden-JSON независимо от доменного кода.
"""
import io
import json
import time
import uuid
from collections import defaultdict
from decimal import Decimal
from pathlib import Path
from typing import Any
import pytest
from httpx import AsyncClient
from loguru import logger
from openpyxl import load_workbook
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.aliases import normalize_alias
from src.domain.osv_mapping import normalize_account_code
from src.models.access import AppUser
from src.models.consolidation import ConsolidationDocument
from src.models.directories import RegionalOffice
from src.models.enums import CounterpartyMatchKind, DocumentStatus
from src.models.files import Document
from src.models.osv import OsvEntry
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group
from tests.fixtures_helpers import map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
HTTP_OK = 200
HTTP_CREATED = 201
GOLDEN = Path("golden") / "expected_osv_parse"
KA_RF = "ka_rf@armka.local"
ROSTOV = "Ростовский региональный филиал" # филиал dev-пользователя ka_rf (в плане — СПб РФ: у ka_rf его нет)
RB_INN = "3528052718" # АО «Русский бисквит» (ЕЦП-выписка 01.04.26)
SCHASTL_INN = "3528104476" # ООО «Пекарня Счастливочная» (там же)
SCHASTL_FILE_NAME = "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" # в файлах РБ контрагент записан «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
RB_NAME_IN_SCHASTL = "Русский бисквит"
TIME_BUDGET_SECONDS = 60.0
Q1, Q2, Q3 = "Q1", "Q2", "Q3"
SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
def _is_schastl(name: str | None) -> bool:
return SCHASTL_FILE_NAME.casefold() in normalize_alias(name or "")
def _golden_files(fixtures_dir: Path, folder: str) -> list[Path]:
paths = [
p for quarter in (1, 2, 3) for p in sorted((fixtures_dir / GOLDEN / folder / f"2024-Q{quarter}").glob("*.json"))
]
assert paths, folder
return paths
def _golden_rows(fixtures_dir: Path, folder: str, account_root: str) -> dict[str, list[dict[str, Any]]]:
"""Строки-контрагенты golden-снимков ОСВ счёта `account_root` по кварталам — для независимого расчёта."""
result: dict[str, list[dict[str, Any]]] = {Q1: [], Q2: [], Q3: []}
for path in _golden_files(fixtures_dir, folder):
for sheet in json.loads(path.read_text("utf-8"))["documents"]:
header = sheet["header"]
if sheet["document_type"] != "OSV" or header["account_code_raw"].split(".")[0] != account_root:
continue
result[header["period_quarter"]].extend(r for r in sheet["rows"] if r["group_type"] == "COUNTERPARTY")
return result
def _key(row: dict[str, Any]) -> tuple[str, str, str]:
return (
normalize_account_code(row["account_code_raw"]),
(row["counterparty_name"] or "").strip(),
(row["counterparty_inn"] or "").replace(" ", ""),
)
def _dec(value: str | None) -> Decimal:
return Decimal(value) if value is not None else Decimal(0)
async def _get(client: AsyncClient, url: str, **params: Any) -> Any:
response = await client.get(url, params=params)
assert response.status_code == HTTP_OK, response.text
return response.json()
async def _all_rows(client: AsyncClient, version_url: str, **params: Any) -> list[dict[str, Any]]:
page = await _get(client, f"{version_url}/osv", limit=2000, **params)
assert page["total"] == len(page["items"]), "свод не помещается в одну страницу"
items: list[dict[str, Any]] = page["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 - сквозной сценарий в одном месте
auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path
) -> None:
started = time.perf_counter()
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
group = await make_company_group(seeded, name="ГК Русский бисквит", office=office)
gsk = await make_related_group(seeded, group=group, name="ГСК Русский бисквит")
rb = await make_legal_entity(seeded, name="АО «Русский бисквит»", inn=RB_INN, group=group)
schastl = await make_legal_entity(seeded, name="ООО «Пекарня Счастливочная»", inn=SCHASTL_INN, group=group)
for entity in (rb, schastl):
await make_membership(seeded, related_group=gsk, entity=entity)
documents: list[Document] = []
for folder, entity in (("rb", rb), ("schastl", schastl)):
for path in _golden_files(fixtures_dir, folder):
documents.extend(await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=user.id))
assert len(documents) == 11 + 11 # РБ: Q1 2, Q2 2, Q3 7; Счастливочная: Q1 3, Q2 3, Q3 5
client = await auth_client(KA_RF)
for document in documents:
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, (document.osv_account_code, document.period_quarter, response.text)
created = await client.post(
"/consolidations",
json={"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": 2024, "quarter": "Q3"},
)
assert created.status_code == HTTP_CREATED, created.text
card = created.json()
assert card["status"] == "INITIATED"
version_id = card["id"]
version_url = f"/consolidations/versions/{version_id}"
bound = (
await seeded.execute(
select(func.count())
.select_from(ConsolidationDocument)
.where(ConsolidationDocument.consolidation_version_id == uuid.UUID(version_id))
)
).scalar_one()
logger.info("привязано документов к версии: {}", bound)
assert bound == len(documents) # «Фикстуры, подтверждённые до консолидации»: подхвачены все подтверждённые ОСВ
locked = await client.post(f"{version_url}/lock")
assert locked.status_code == HTTP_CREATED, locked.text
summary = await _get(client, f"{version_url}/osv/summary")
rows = await _all_rows(client, version_url)
logger.info(
"сводная ОСВ РБ 2024 Q3: строк {}, сводка {}", len(rows), {k: v for k, v in summary.items() if k != "gaps"}
)
print(
f"\nSVOD: rows={len(rows)} summary={ {k: v for k, v in summary.items() if k != 'gaps'} } gaps={summary['gaps']}"
) # noqa: T201
assert summary["rows_total"] == len(rows)
# строки свода: 60 и 62 у обеих, 76 у Счастливочной
present = {(row["legal_entity_id"], row["account_root"]) for row in rows}
for account in ("60", "62"):
assert (str(rb.id), account) in present
assert (str(schastl.id), account) in present
assert (str(schastl.id), "76") in present
# пробелы: у РБ нет квартальных ОСВ 66/67/76 за Q1–Q2, у Счастливочной — 01/02
gaps = {(g["legal_entity_id"], g["account_code"]): g["missing_quarters"] for g in summary["gaps"]}
for account in ("66", "67", "76"):
assert set(gaps[(str(rb.id), account)]) >= {Q1, Q2}
for account in ("01", "02"):
assert set(gaps[(str(schastl.id), account)]) >= {Q1, Q2}
# чек-лист: ОСВ 58 РБ не подтверждена (есть только 58.03)
checklist = await _get(client, f"{version_url}/checklist")
column = next(
i for i, rule in enumerate(checklist["rules"]) if (rule["document_type"], rule["account"]) == ("OSV", "58")
)
rb_row = next(r for r in checklist["rows"] if r["legal_entity_id"] == str(rb.id))
assert rb_row["cells"][column]["status"] != "CONFIRMED"
for account in ("60", "62"): # ОСВ 60/62 РБ за Q1–Q3 подтверждены до консолидации — ячейка CONFIRMED, три документа
cell = rb_row["cells"][
next(i for i, r in enumerate(checklist["rules"]) if (r["document_type"], r["account"]) == ("OSV", account))
]
assert (cell["status"], len(cell["document_ids"])) == ("CONFIRMED", 3), account # noqa: PLR2004
# несопоставленные строки РБ «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
unmapped = await _all_rows(client, version_url, unmapped="true", legal_entity_id=str(rb.id))
target = [row for row in unmapped if _is_schastl(row["counterparty_name"])]
assert target
first = target[0]
unmapped_before = summary["unmapped_total"]
mapped = await client.post(
f"{version_url}/osv/entries/{first['entry_ids'][0]}/counterparty", json={"legal_entity_id": str(schastl.id)}
)
assert mapped.status_code == HTTP_OK, mapped.text
body = mapped.json()
assert body["row"]["counterparty_type"] == "GSK"
assert body["propagated"] >= 1
after = await _get(client, f"{version_url}/osv/summary")
rows_after = await _all_rows(client, version_url)
# `propagated` считает записи `osv_entry` (в том числе в парах-пробелах 66/76, строк в своде не дающих), а
# `unmapped_total` — строки свода: квартальные записи одного контрагента склеены (D-2). Поэтому `unmapped_total`
# падает на число склеенных строк свода, получивших контрагента, а «1 + propagated» сверяется с записями в БД
# (design #79).
before_by_entries = {entry_id: row for row in rows for entry_id in row["entry_ids"]}
changed = {
tuple(row["entry_ids"]) for row in rows_after
if row["counterparty_id"] is not None and before_by_entries[row["entry_ids"][0]]["counterparty_id"] is None
} # fmt: skip
assert len(changed) >= 2 # noqa: PLR2004 - счета 60 и 62
stored = (
await seeded.execute(
select(OsvEntry).where(OsvEntry.owner_legal_entity_id == rb.id, OsvEntry.counterparty_name.is_not(None))
.execution_options(populate_existing=True)
)
).scalars().all() # fmt: skip
named = [entry for entry in stored if _is_schastl(entry.counterparty_name)]
assert len(named) == 1 + body["propagated"]
assert {entry.counterparty_id for entry in named} == {schastl.id}
assert {entry.counterparty_match_kind for entry in named} == {
CounterpartyMatchKind.MANUAL,
CounterpartyMatchKind.PROPAGATED,
}
assert {entry.account_root for entry in named} >= {"60", "62", "66", "76"} # включая пары-пробелы
assert after["unmapped_total"] == unmapped_before - len(changed)
same_name = [r for r in rows_after if r["legal_entity_id"] == str(rb.id) and _is_schastl(r["counterparty_name"])]
assert {r["account_root"] for r in same_name} >= {"60", "62"} # 66 и 76 РБ — пробелы, строк в своде нет
assert all(r["counterparty_id"] == str(schastl.id) and r["counterparty_type"] == "GSK" for r in same_name)
# Счастливочная: контрагент «Русский бисквит» с ИНН — GSK по ИНН
inn_rows = [
r
for r in rows_after
if r["legal_entity_id"] == str(schastl.id)
and r["counterparty_name"] == RB_NAME_IN_SCHASTL
and r["counterparty_inn"] == RB_INN
]
assert inn_rows
assert all(r["counterparty_type"] == "GSK" and r["match_kind"] == "INN" for r in inn_rows)
# сверка суммы периодов с golden: контрагент РБ по счёту 60, суммы Q1+Q2+Q3 считаются здесь, а не доменом
golden = _golden_rows(fixtures_dir, "rb", "60")
keys = {q: defaultdict(list) for q in golden}
for quarter, quarter_rows in golden.items():
for row in quarter_rows:
keys[quarter][_key(row)].append(row)
common = [
k for k in keys[Q1] if k in keys[Q2] and k in keys[Q3] and any(_dec(r["turnover_debit"]) for r in keys[Q2][k])
]
assert common
checked = 0
for key in common[:5]:
account_code, name, inn = key
found = [
r for r in rows_after
if r["legal_entity_id"] == str(rb.id) and r["account_code"] == account_code
and (r["counterparty_name"] or "").strip() == name and (r["counterparty_inn"] or "").replace(" ", "") == inn
] # fmt: skip
assert len(found) == 1, key
for field in SUMS:
first_q, last_q = keys[Q1][key], keys[Q3][key]
if field.startswith("opening"):
expected = sum(_dec(r[field]) for r in first_q)
elif field.startswith("closing"):
expected = sum(_dec(r[field]) for r in last_q)
else:
expected = sum(_dec(r[field]) for q in (Q1, Q2, Q3) for r in keys[q][key])
assert Decimal(found[0][field]) == expected, (key, field)
assert found[0]["is_cumulative"] is False and len(found[0]["entry_ids"]) >= 3 # noqa: PLR2004
checked += 1
assert checked > 0
# экспорт
export = await client.get(f"{version_url}/osv/export")
assert export.status_code == HTTP_OK, export.text
book = load_workbook(io.BytesIO(export.content))
main = list(book[book.sheetnames[0]].iter_rows(values_only=True))
assert len(main) - 1 == after["rows_total"]
elapsed = time.perf_counter() - started
logger.info("сквозной сценарий РБ: {:.1f} с, строк свода {}", elapsed, len(rows_after))
assert elapsed <= TIME_BUDGET_SECONDS
assert all(d.status is not DocumentStatus.NEEDS_CHECK for d in documents)

View File

@ -34,6 +34,7 @@ from src.models.osv import OsvEntry
from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import map_file
from tests.factories import make_legal_entity, make_stored_file
from tests.fixtures_helpers import golden_result
GOLDEN_DIR = Path("golden") / "expected_osv_parse"
EXPECTED_SNAPSHOTS = 82 # результатов ОСВ в fixtures (rb, schastl, synthetic)
@ -48,13 +49,6 @@ def _snapshots(fixtures_dir: Path) -> list[Path]:
return sorted((fixtures_dir / GOLDEN_DIR).rglob("*.json"))
def _payload(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult:
data = json.loads(path.read_text("utf-8"))
# у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна)
data["service"] = {"version": "golden", **data["service"]}
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
def _decimal(value: str | None) -> Decimal | None:
return Decimal(value) if value is not None else None
@ -116,7 +110,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
job_status=RecognitionStatus.done,
original_name=path.name,
)
result = _payload(path, job_id=job.id, file_id=file.id)
result = golden_result(path, job_id=job.id, file_id=file.id)
started = time.perf_counter()
await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC))
mapping_seconds += time.perf_counter() - started

View File

@ -252,9 +252,9 @@
- Сценарий: seed ГК «Русский бисквит» (СПб РФ) с ЮЛ АО РБ (ИНН 3528052718) и ООО «Пекарня Счастливочная» (ИНН из `fixtures/directories/ecp-export-russkiy-biskvit.xlsx`, см. `test_import_full_export`) и ГСК из них; маппинг golden `rb/2024-Q1..Q3/*.json` и `schastl/2024-Q1..Q3/*.json` через `map_file` (реальный `CounterpartyIndex`); подтверждение всех документов через `POST /documents/{id}/confirm` (`ka_rf`); `POST /consolidations` 2024 Q3 → `INITIATED` (подхват) → `POST …/lock` → `GET …/osv/summary`: строки есть по счетам 60 и 62 у обеих и 76 у Счастливочной; `gaps` содержат РБ 66/67/76 с `missing_quarters ⊇ [Q1, Q2]` (и Счастливочной 01/02); ячейка чек-листа ОСВ 58 РБ — не `CONFIRMED` (есть только 58.3); `GET …/osv?unmapped=true` содержит строки РБ «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (имя как в файле, без учёта регистра — найти по `normalize_alias`); `POST …/osv/entries/{id}/counterparty` → `GSK`, `propagated ≥ 1`; повторный `summary.unmapped_total` уменьшился на `1 + propagated`; все строки с этим именем во всех счетах РБ — `GSK`; строка Счастливочной «Русский бисквит» с ИНН — `GSK`, `match_kind = INN`; `GET …/osv/export` открывается `openpyxl`, число строк = `rows_total`.
- Время теста ≤ 60 с; выводить в лог число строк свода.
- [ ] **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).
- [ ] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»`.
- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления).
---

View File

@ -263,3 +263,5 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить; `model_dump(mode="json", exclude_none=True)` отличается от буквального `model_dump` плана (M-5): UUID и enum должны быть JSON-сериализуемы для `audit_event` |
| 77 | 2026-10-06 | R-12: все текстовые ячейки экспорта (компания, ИНН, счёт, период, контрагент, ИНН контрагента, «Сопоставлено с», тип, способ, лист «Пробелы») проходят `_put_text`: запрещённые XML-символы (`ILLEGAL_CHARACTERS_RE`) вырезаются, тип ячейки принудительно `"s"`, поэтому `=`, `+`, `-`, `@` в начале не становятся формулой, а текст читается обратно без изменений | файлы внешние, аналитики открывают выгрузку в Excel: без этого `=HYPERLINK(...)` из чужого файла выполнился бы в их книге, а управляющий символ давал `IllegalCharacterError` → 500 | текст ячейки с ведущим `=` больше не вычисляется — это желаемо; вырезанный управляющий символ из выгрузки пропадает |
| 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 вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) |

View File

@ -59,6 +59,8 @@
- [ ] 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 после исправления.
- [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md
(новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9).