From af91643811ea149d3145839f23bf914c69884271 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:25:30 +0400 Subject: [PATCH] =?UTF-8?q?test(consolidation):=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=204=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=D0=B0=D1=85?= =?UTF-8?q?=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=C2=AB=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B1=D0=B8=D1=81=D0=BA=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер map_golden_snapshot, сценарий маппинг golden -> подтверждение -> консолидация 2024 Q3 -> свод, сопоставление Счастливочной, экспорт, независимая сверка склейки периодов. Тест помечен xfail(strict): на реальных данных нарастающий Q1 (is_cumulative=true) не привязывается к версии Q3 и не считается квартальным (fits / select_sources) — свод пуст; подробности в отчёте задачи. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/fixtures_helpers.py | 58 ++++ api/tests/test_consolidation_fixtures.py | 297 ++++++++++++++++++ api/tests/test_mapping_golden.py | 10 +- .../plans/2026-10-06-slice-4-consolidation.md | 4 +- .../changes/slice-4-consolidation/design.md | 2 + .../changes/slice-4-consolidation/tasks.md | 4 +- 6 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 api/tests/test_consolidation_fixtures.py diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 242ece7..2987c65 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -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()) diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py new file mode 100644 index 0000000..26e677f --- /dev/null +++ b/api/tests/test_consolidation_fixtures.py @@ -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) diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 35392e4..8f307f1 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -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 diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 0573cae..ad9e753 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -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 закрыть после исправления). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 1ccdb30..44c748d 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -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 вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 2a1b473..e1a2326 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -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).