arm-core/api/tests/test_consolidation_fixtures.py
Andrey Epifancev d3c4964e92 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
2026-10-06 07:36:24 +04:00

288 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сквозной сценарий Среза 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
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)