feat(statements): parent_code у строк формы ББ/ОФР, общая subline_parents (#118, AURORA-1527)

Правило «подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами,
иначе к строке из первых четырёх цифр» вынесено в subline_parents; им пользуются проверка
«сумма подстрок = родитель» и ответ GET /documents/{id}/statement (поле lines[].parent_code,
вычисляется, в БД не хранится).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 18:48:50 +04:00
parent 18225153f1
commit 11b0216cff
6 changed files with 131 additions and 22 deletions

View File

@ -568,7 +568,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`, - **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`,
`report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы
(`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `checks` — (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `lines[].parent_code` — у пятизначной подстроки код её четырёхзначной строки-родителя (правило проверки «сумма подстрок = родитель», `domain/statement_checks.subline_parents`: ближайшая выше строка с теми же тремя цифрами, иначе строка с кодом из первых четырёх цифр; родитель считается по строкам, участвующим в проверках, — достроенные из справочника пустые строки родителями не бывают), у остальных строк `null`; поле вычисляется на лету, в БД не хранится; `checks` —
`{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной
проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`.
Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется. Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется.

View File

@ -27,6 +27,7 @@ __all__ = [
"cross_form_check", "cross_form_check",
"load_check_rules", "load_check_rules",
"run_checks", "run_checks",
"subline_parents",
] ]
Form = Literal["BALANCE", "INCOME"] Form = Literal["BALANCE", "INCOME"]
@ -136,13 +137,16 @@ def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decima
return False, expected, plain return False, expected, plain
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: def subline_parents(codes: Sequence[str]) -> dict[str, str]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками. Порядок строк в БД не """Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (единое правило §7 для
хранится (DBML порядка не знает): «по порядку» — это порядок кодов, в каком их отдаёт `statement_amounts` проверки «сумма подстрок = родитель» и для поля `parent_code` ответа формы; AURORA-1527).
(M-9 2b: подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами)."""
low, high = CODE_RANGES[form] Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из
parents = {code for code in codes if len(code) == _PARENT_LEN} её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится
children: dict[str, list[str]] = {} (DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `statement_amounts`
отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`."""
present = {code for code in codes if len(code) == _PARENT_LEN}
result: dict[str, str] = {}
last_parent: str | None = None last_parent: str | None = None
for code in codes: for code in codes:
if len(code) == _PARENT_LEN: if len(code) == _PARENT_LEN:
@ -151,12 +155,18 @@ def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
if len(code) != _PARENT_LEN + 1: if len(code) != _PARENT_LEN + 1:
continue continue
if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]: if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]:
parent = last_parent result[code] = last_parent
elif code[:_PARENT_LEN] in parents: elif code[:_PARENT_LEN] in present:
parent = code[:_PARENT_LEN] result[code] = code[:_PARENT_LEN]
else: return result
continue
children.setdefault(parent, []).append(code)
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками; родители — `subline_parents`."""
low, high = CODE_RANGES[form]
children: dict[str, list[str]] = {}
for kid, parent in subline_parents(codes).items():
children.setdefault(parent, []).append(kid)
rules: list[RelationRule] = [] rules: list[RelationRule] = []
for parent, kids in children.items(): for parent, kids in children.items():
if parent.isdigit() and low <= int(parent) <= high: if parent.isdigit() and low <= int(parent) <= high:

View File

@ -7,7 +7,7 @@ design D-5, D-14).
import datetime import datetime
import uuid import uuid
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Literal from typing import Annotated, Literal
@ -81,6 +81,9 @@ class LineOut(BaseModel):
line_code_confidence: Decimal | None line_code_confidence: Decimal | None
line_name: str | None line_name: str | None
line_name_confidence: Decimal | None line_name_confidence: Decimal | None
# код четырёхзначной строки-родителя у пятизначной подстроки (вычисляется по `subline_parents`, в БД не хранится);
# у остальных строк, в т. ч. достроенных из справочника, — None
parent_code: str | None
values: list[ValueOut] values: list[ValueOut]
@ -177,6 +180,7 @@ def _lines_out(
columns: Sequence[FinancialStatementColumn], columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
template: Sequence[TemplateLine], template: Sequence[TemplateLine],
parents: Mapping[str, str],
) -> list[LineOut]: ) -> list[LineOut]:
"""Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду.""" """Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду."""
column_index = {column.id: column.column_index for column in columns} column_index = {column.id: column.column_index for column in columns}
@ -194,6 +198,7 @@ def _lines_out(
line_code_confidence=line.line_code_confidence, line_code_confidence=line.line_code_confidence,
line_name=line.line_name, line_name=line.line_name,
line_name_confidence=line.line_name_confidence, line_name_confidence=line.line_name_confidence,
parent_code=parents.get(line.line_code) if line.line_code is not None else None,
values=cells, values=cells,
) )
) )
@ -209,6 +214,7 @@ def _lines_out(
line_code_confidence=None, line_code_confidence=None,
line_name=item.name, line_name=item.name,
line_name_confidence=None, line_name_confidence=None,
parent_code=None,
values=[_empty_value(column) for column in columns], values=[_empty_value(column) for column in columns],
) )
) )
@ -234,6 +240,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
*, *,
edition: str | None, edition: str | None,
template: Sequence[TemplateLine], template: Sequence[TemplateLine],
parents: Mapping[str, str],
) -> StatementOut: ) -> StatementOut:
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по
справочнику редакции (D-3).""" справочнику редакции (D-3)."""
@ -278,7 +285,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
address=meta.address, address=meta.address,
), ),
columns=[ColumnOut.model_validate(column) for column in columns], columns=[ColumnOut.model_validate(column) for column in columns],
lines=_lines_out(columns, lines, template), lines=_lines_out(columns, lines, template, parents),
checks=ChecksOut( checks=ChecksOut(
failed=[_failed(check) for check in report.failed], failed=[_failed(check) for check in report.failed],
soft_failed=[_failed(check) for check in report.soft_failed], soft_failed=[_failed(check) for check in report.soft_failed],

View File

@ -15,7 +15,14 @@ from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain.osv_mapping import counterparty_check from src.domain.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope from src.domain.policy import VisibilityScope
from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks from src.domain.statement_checks import (
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
from src.domain.statement_lines import TemplateLine, load_line_catalog from src.domain.statement_lines import TemplateLine, load_line_catalog
from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
@ -112,13 +119,16 @@ class StatementService:
async def checks_report( async def checks_report(
self, document: Document, meta: FinancialStatementMeta self, document: Document, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None]: ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]:
"""Проверки §7 по текущим суммам формы и мягкая межформенная проверка (D-5).""" """Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок
(`parent_code` ответа; AURORA-1527). Родители считаются по тем же кодам и в том же порядке
(`statement_amounts`), что и проверка «сумма подстрок = родитель», — ответ и проверка не расходятся."""
rules = load_check_rules() rules = load_check_rules()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id) column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, rules) report = run_checks(document.document_type.value, amounts, column_indexes, rules)
parents = subline_parents([code for code, _ in amounts])
balance_1370, income_2400 = await repo.pair_amounts(self._session, document) balance_1370, income_2400 = await repo.pair_amounts(self._session, document)
return report, cross_form_check(balance_1370, income_2400, rules.tolerance) return report, cross_form_check(balance_1370, income_2400, rules.tolerance), parents
async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: async def get(self, document_id: uuid.UUID) -> schemas.StatementOut:
document, meta = await self._visible_statement(document_id) document, meta = await self._visible_statement(document_id)
@ -128,10 +138,12 @@ class StatementService:
"""Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и """Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и
ответами правки/акцепта). В БД ничего не пишет.""" ответами правки/акцепта). В БД ничего не пишет."""
columns, lines = await repo.load_form(self._session, meta.id) columns, lines = await repo.load_form(self._session, meta.id)
report, cross = await self.checks_report(document, meta) report, cross, parents = await self.checks_report(document, meta)
edition = edition_for(document.reporting_date) edition = edition_for(document.reporting_date)
template = load_line_catalog().lines(edition, document.document_type.value) template = load_line_catalog().lines(edition, document.document_type.value)
return schemas.statement_out(document, meta, columns, lines, report, cross, edition=edition, template=template) return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition, template=template, parents=parents
)
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""

View File

@ -40,6 +40,8 @@ KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал" ROSTOV = "Ростовский региональный филиал"
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
# ББ с пятизначными строками детализации
UNAGRANDE_BALANCE = GOLDEN_STATEMENTS / "unagrande-group" / "2025-Q3" / "ББ. Лист 1 3кв.25.json"
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
MEZH_BALANCE_LINES = 37 MEZH_BALANCE_LINES = 37
MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
@ -167,6 +169,7 @@ async def test_get_statement_shape_and_decimal_strings(
"line_code_confidence", "line_code_confidence",
"line_name", "line_name",
"line_name_confidence", "line_name_confidence",
"parent_code",
"values", "values",
} }
value = next(v for v in line["values"] if v["amount"] is not None) value = next(v for v in line["values"] if v["amount"] is not None)
@ -189,6 +192,46 @@ async def test_get_statement_shape_and_decimal_strings(
assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"])
async def test_get_statement_parent_code_of_sublines(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""AURORA-1527: у пятизначной подстроки `parent_code` — её четырёхзначная строка, у остальных (в т. ч. шаблонных) —
`null`; родитель тот же, что у проверки «сумма подстрок = родитель»."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
parents = {line["line_code"]: line["parent_code"] for line in body["lines"]}
assert parents["11101"] == "1110" and parents["11201"] == "1120" and parents["11510"] == "1150"
assert parents["11904"] == "1190" and parents["12101"] == "1210"
assert all((parent is not None) == (len(code) == 5) for code, parent in parents.items()) # noqa: PLR2004
assert all(line["parent_code"] is None for line in body["lines"] if line["origin"] == "template")
async def test_get_statement_parent_code_matches_subline_check(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Нарушенная «1110 = 11101 + …» строится по тем же подстрокам, что отдаёт `parent_code`."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await set_amount(seeded, document, "1110", Decimal("999999"), current=True)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110")
assert kids == ["11101", "11102", "11103"]
assert f"1110 = {' + '.join(kids)}" in {item["relation"] for item in body["checks"]["soft_failed"]}
async def test_get_statement_orphan_subline_has_no_parent(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Подстрока без четырёхзначной строки в форме остаётся без родителя (и не попадает в проверку)."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await drop_line(seeded, document, "1170")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
parents = {line["line_code"]: line["parent_code"] for line in body["lines"] if line["origin"] == "source"}
assert parents["11701"] is None
async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir, MEZH_INCOME) document = await form(seeded, fixtures_dir, MEZH_INCOME)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)

View File

@ -10,6 +10,7 @@ from src.domain.statement_checks import (
cross_form_check, cross_form_check,
load_check_rules, load_check_rules,
run_checks, run_checks,
subline_parents,
) )
D = Decimal D = Decimal
@ -208,3 +209,39 @@ def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None:
(0, D("0"), D("900")), (0, D("0"), D("900")),
(1, D("0"), D("900")), (1, D("0"), D("900")),
] ]
def test_subline_parents_nearest_parent_above_with_same_group() -> None:
# 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231
codes = ["1231", "1230", "12310", "12320"]
assert subline_parents(codes) == {"12310": "1230", "12320": "1230"}
def test_subline_parents_fallback_to_four_digit_prefix() -> None:
# выше подстроки нет родителя той же группы — родитель по первым четырём цифрам, если такая строка есть
assert subline_parents(["11101", "1110"]) == {"11101": "1110"}
assert subline_parents(["1100", "1110", "1120", "11101"]) == {"11101": "1110"}
def test_subline_parents_orphans_and_non_sublines_are_absent() -> None:
assert subline_parents(["1100", "11101", "1700", "123"]) == {}
assert subline_parents([]) == {}
def test_subline_parents_follow_given_order() -> None:
# порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему
assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"}
assert subline_parents(["1231", "1230", "12310"]) == {"12310": "1230"}
def test_run_checks_and_subline_parents_agree() -> None:
# проверка «сумма подстрок = родитель» строится по тем же родителям, что и `subline_parents`
codes = ["1231", "1230", "12310", "12320", "1110", "11101"]
amounts = {"11101": "5", "1230": "10"}
report = run_checks("BALANCE", _lines(*((code, {0: amounts.get(code, "1")}) for code in codes)), [0], RULES)
parents = subline_parents(codes)
expected = {
f"{parent} = {' + '.join(kid for kid, owner in parents.items() if owner == parent)}"
for parent in set(parents.values())
}
assert {item.relation for item in report.soft_failed} == expected