Merge pull request 'parent_code у строк формы ББ/ОФР, общая subline_parents (#118, AURORA-1527)' (#119) from feat/118-subline-parent into main

This commit is contained in:
aep 2026-10-16 18:35:17 +03:00
commit 423929ce64
6 changed files with 176 additions and 23 deletions

View File

@ -568,7 +568,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`,
`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`; поле вычисляется на лету, в БД не хранится; мягкая проверка «сумма подстрок = родитель» по колонке выполняется, только если хотя бы одна подстрока родителя в ней непустая (`amount` не `null`; ноль — значение), иначе не проверяется; `checks` —
`{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной
проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`.
Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется.

View File

@ -5,7 +5,8 @@
строки); отсутствующий код и пустая сумма в правой части — 0; правая часть считается как записано и с `abs_terms` по
модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше
`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок =
четырёхзначный родитель» — мягкая. Результат не хранится (DBML).
четырёхзначный родитель» — мягкая и только если в колонке есть хоть одна непустая подстрока (AURORA-1527).
Результат не хранится (DBML).
"""
import functools
@ -27,6 +28,7 @@ __all__ = [
"cross_form_check",
"load_check_rules",
"run_checks",
"subline_parents",
]
Form = Literal["BALANCE", "INCOME"]
@ -136,13 +138,16 @@ def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decima
return False, expected, plain
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками. Порядок строк в БД не
хранится (DBML порядка не знает): «по порядку» — это порядок кодов, в каком их отдаёт `statement_amounts`
(M-9 2b: подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами)."""
low, high = CODE_RANGES[form]
parents = {code for code in codes if len(code) == _PARENT_LEN}
children: dict[str, list[str]] = {}
def subline_parents(codes: Sequence[str]) -> dict[str, str]:
"""Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (единое правило §7 для
проверки «сумма подстрок = родитель» и для поля `parent_code` ответа формы; AURORA-1527).
Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из
её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится
(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
for code in codes:
if len(code) == _PARENT_LEN:
@ -151,12 +156,18 @@ def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
if len(code) != _PARENT_LEN + 1:
continue
if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]:
parent = last_parent
elif code[:_PARENT_LEN] in parents:
parent = code[:_PARENT_LEN]
else:
continue
children.setdefault(parent, []).append(code)
result[code] = last_parent
elif code[:_PARENT_LEN] in present:
result[code] = code[:_PARENT_LEN]
return result
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] = []
for parent, kids in children.items():
if parent.isdigit() and low <= int(parent) <= high:
@ -189,9 +200,14 @@ def run_checks(
# присутствие цели = наличие строки формы; пустая сумма (None) = 0, как `_amounts` в tabular-parser: оба
# производителя отдают значение на каждую колонку каждой кодированной строки (пустая ячейка — amount=None)
amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines}
filled = {code: values.get(column_index) is not None for code, values in lines}
for rule in [*explicit, *sublines]:
if rule not in sublines and rule.target not in amounts:
continue
# AURORA-1527: детализации нет (все подстроки родителя в колонке пусты, `None`) — «родитель = Σ подстрок»
# по колонке не проверяется; ноль — значение. Правило кода, словарь не меняется.
if rule in sublines and all(filled.get(code) is not True for _, code in rule.terms):
continue
holds, expected, actual = _holds(amounts, rule, rules.tolerance)
if holds:
continue

View File

@ -7,7 +7,7 @@ design D-5, D-14).
import datetime
import uuid
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from decimal import Decimal
from typing import Annotated, Literal
@ -81,6 +81,9 @@ class LineOut(BaseModel):
line_code_confidence: Decimal | None
line_name: str | None
line_name_confidence: Decimal | None
# код четырёхзначной строки-родителя у пятизначной подстроки (вычисляется по `subline_parents`, в БД не хранится);
# у остальных строк, в т. ч. достроенных из справочника, — None
parent_code: str | None
values: list[ValueOut]
@ -177,6 +180,7 @@ def _lines_out(
columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]],
template: Sequence[TemplateLine],
parents: Mapping[str, str],
) -> list[LineOut]:
"""Строки БД с ячейками по всем колонкам + строки справочника, которых нет в форме (design D-3), по коду."""
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_name=line.line_name,
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,
)
)
@ -209,6 +214,7 @@ def _lines_out(
line_code_confidence=None,
line_name=item.name,
line_name_confidence=None,
parent_code=None,
values=[_empty_value(column) for column in columns],
)
)
@ -234,6 +240,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
*,
edition: str | None,
template: Sequence[TemplateLine],
parents: Mapping[str, str],
) -> StatementOut:
"""Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по
справочнику редакции (D-3)."""
@ -278,7 +285,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
address=meta.address,
),
columns=[ColumnOut.model_validate(column) for column in columns],
lines=_lines_out(columns, lines, template),
lines=_lines_out(columns, lines, template, parents),
checks=ChecksOut(
failed=[_failed(check) for check in report.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.domain.osv_mapping import counterparty_check
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_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
from src.models.directories import LegalEntity
@ -112,13 +119,16 @@ class StatementService:
async def checks_report(
self, document: Document, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None]:
"""Проверки §7 по текущим суммам формы и мягкая межформенная проверка (D-5)."""
) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]:
"""Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок
(`parent_code` ответа; AURORA-1527). Родители считаются по тем же кодам и в том же порядке
(`statement_amounts`), что и проверка «сумма подстрок = родитель», — ответ и проверка не расходятся."""
rules = load_check_rules()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
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)
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:
document, meta = await self._visible_statement(document_id)
@ -128,10 +138,12 @@ class StatementService:
"""Ответ формы по текущему состоянию БД, достроенный по справочнику редакции (design D-3; используется и
ответами правки/акцепта). В БД ничего не пишет."""
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)
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, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""

View File

@ -40,6 +40,8 @@ KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал"
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_BALANCE_LINES = 37
MEZH_BALANCE_TEMPLATE_ONLY = {"1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
@ -167,6 +169,7 @@ async def test_get_statement_shape_and_decimal_strings(
"line_code_confidence",
"line_name",
"line_name_confidence",
"parent_code",
"values",
}
value = next(v for v in line["values"] if v["amount"] is not None)
@ -189,6 +192,58 @@ 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"])
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)
await set_amount(seeded, document, "11101", Decimal("1"), 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_no_subline_warnings_without_detail(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""AURORA-1527: все подстроки родителя пусты — «1150 = 11501 + …», «1170 = 11701», «1210 = 12101» не выдаются."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
soft = [item["relation"] for item in body["checks"]["soft_failed"]]
assert not any(relation.startswith(("1150 =", "1170 =", "1210 =")) for relation in soft), soft
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:
document = await form(seeded, fixtures_dir, MEZH_INCOME)
client = await auth_client(KA_RF)

View File

@ -10,6 +10,7 @@ from src.domain.statement_checks import (
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
D = Decimal
@ -208,3 +209,65 @@ def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None:
(0, 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
def test_subline_check_skipped_when_all_sublines_empty_in_column() -> None:
# AURORA-1527: детализации нет (все подстроки пусты) — «1150 = 11501 + …» по колонке не проверяется
lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: None}), ("11502", {0: None, 1: None}))
assert _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") == []
def test_subline_check_is_per_column() -> None:
# в колонке 0 подстроки пусты (не проверяется), в колонке 1 заполнены и не сходятся (нарушение)
lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: "10"}), ("11502", {0: None, 1: None}))
failed = _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502")
assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, D("90"), D("10"))]
def test_subline_zero_is_a_value() -> None:
# ноль — значение: подстроки с нулями проверяются как раньше
lines = _lines(("1150", {0: "90"}), ("11501", {0: "0"}), ("11502", {0: None}))
assert len(_by_relation(run_checks("BALANCE", lines, [0], RULES).soft_failed, "1150 = 11501 + 11502")) == 1
def test_empty_sublines_do_not_affect_other_relations() -> None:
# прочие соотношения §7 не затронуты: пустая цель 1200 при ненулевых слагаемых — по-прежнему жёсткое нарушение
lines = _lines(("1210", {0: "5"}), ("12101", {0: None}), ("1200", {0: None}), ("1600", {0: "5"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert [f.relation for f in report.failed if f.relation.startswith("1200 =")] != []