From 1dc510fbb5643d86880fa6944eb5e0956f4fb3a3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:52:21 +0400 Subject: [PATCH] =?UTF-8?q?feat(domain):=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D1=8C=20=D0=B8=D0=BC=D1=91=D0=BD=20=D1=81=20=D1=81=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D0=B0=D0=BC=D0=B8=20(=D0=A0-20)?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=BE=D0=B2=D0=BF=D0=B0=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B9=20=C2=A711.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/rules/statement_lines.yaml | 12 ++++- api/src/domain/statement_lines.py | 50 +++++++++++++++---- api/src/domain/statement_names.py | 51 +++++++++++++++++++ api/src/services/statements.py | 9 ++-- api/tests/test_api_statements.py | 4 +- api/tests/test_domain_statement_lines.py | 63 ++++++++++++++++++------ api/tests/test_domain_statement_names.py | 40 +++++++++++++++ 7 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 api/src/domain/statement_names.py create mode 100644 api/tests/test_domain_statement_names.py diff --git a/api/rules/statement_lines.yaml b/api/rules/statement_lines.yaml index ac0e1be..7dbac1f 100644 --- a/api/rules/statement_lines.yaml +++ b/api/rules/statement_lines.yaml @@ -1,4 +1,6 @@ -# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», +# Словарь имён: код → наименования по редакциям, несколько имён на код (Р-20; порядок — contracts/statement-code-orders.yaml). +# Первое имя кода — справочное имя и состав формы; остальные — синонимы и законные повторы для совпадения наименований +# (§11.9). Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (05-domain-rules §7 «Ядро», # design D-2 change statement-template-lines). Порядок строк в списке — порядок формы. # Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже. # Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после @@ -72,7 +74,10 @@ editions: - id: fsbu4 forms: BALANCE: + - {code: "1105", name: "Гудвил"} - {code: "1110", name: "Нематериальные активы"} + - {code: "1130", name: "Нематериальные поисковые активы"} + - {code: "1140", name: "Материальные поисковые активы"} - {code: "1150", name: "Основные средства"} - {code: "1160", name: "Инвестиционная недвижимость"} - {code: "1170", name: "Финансовые вложения"} @@ -82,6 +87,7 @@ editions: - {code: "1210", name: "Запасы"} - {code: "1215", name: "Долгосрочные активы к продаже"} - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} + - {code: "1220", name: "НДС, начисленный по отгрузке"} - {code: "1230", name: "Дебиторская задолженность"} - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} - {code: "1250", name: "Денежные средства и денежные эквиваленты"} @@ -102,6 +108,8 @@ editions: - {code: "1400", name: "Итого по разделу IV"} - {code: "1510", name: "Заемные средства"} - {code: "1520", name: "Кредиторская задолженность"} + - {code: "1520", name: "Обязательство по аренде"} + - {code: "1530", name: "Доходы будущих периодов"} - {code: "1540", name: "Оценочные обязательства"} - {code: "1550", name: "Прочие краткосрочные обязательства"} - {code: "1500", name: "Итого по разделу V"} @@ -122,7 +130,9 @@ editions: - {code: "2410", name: "Налог на прибыль организаций"} - {code: "2411", name: "текущий налог на прибыль организаций"} - {code: "2412", name: "отложенный налог на прибыль организаций"} + - {code: "2410", name: "Налог при упрощенной системе налогообложения"} - {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"} + - {code: "2460", name: "Прочее"} - {code: "2400", name: "Чистая прибыль (убыток)"} - {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"} - {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"} diff --git a/api/src/domain/statement_lines.py b/api/src/domain/statement_lines.py index d1ef2a4..d20ca9c 100644 --- a/api/src/domain/statement_lines.py +++ b/api/src/domain/statement_lines.py @@ -1,9 +1,10 @@ -"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»; -change statement-template-lines, design D-2). +"""Словарь имён строк форм ББ/ОФР по редакциям — состав формы для экрана проверки (05-domain-rules §7 «Ядро»; +change statement-template-lines, design D-2) и справочные имена для совпадения наименований (§11.9, Р-20). -Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает -`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме: -строки источника, которых в нём нет, остаются как есть. +Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Код в редакции может стоять +несколько раз (синонимы и законные повторы, Р-20): состав формы берёт первое имя, сравнение наименований — любое. +Редакцию выбирает `statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не +переименовывает в форме: строки источника, которых в нём нет, остаются как есть. """ import functools @@ -28,14 +29,46 @@ class TemplateLine: @dataclass(frozen=True) class LineCatalog: + """`editions[редакция][форма]` — все записи словаря в порядке формы, повтор кода допустим.""" + editions: dict[str, dict[str, tuple[TemplateLine, ...]]] - def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: - """Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто.""" + def _entries(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: if edition is None: return () return self.editions.get(edition, {}).get(form, ()) + def template_lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: + """Строки формы в порядке формы: по одной на код, первое имя, порядок первого появления. + Без редакции или для чужой формы — пусто.""" + seen: set[str] = set() + result: list[TemplateLine] = [] + for line in self._entries(edition, form): + if line.code not in seen: + seen.add(line.code) + result.append(line) + return tuple(result) + + def reference_name(self, edition: str | None, form: str, code: str) -> str | None: + """Первое имя кода в редакции (P3-11); кода нет — `None`.""" + for line in self._entries(edition, form): + if line.code == code: + return line.name + return None + + def names(self, form: str, code: str) -> tuple[str, ...]: + """Все имена кода во всех редакциях без повторов, в порядке появления.""" + result: dict[str, None] = {} + for forms in self.editions.values(): + for line in forms.get(form, ()): + if line.code == code: + result.setdefault(line.name) + return tuple(result) + + def edition_codes(self, edition: str, form: str) -> frozenset[str]: + """Коды формы в редакции.""" + return frozenset(line.code for line in self._entries(edition, form)) + def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]: if not isinstance(items, list): @@ -44,9 +77,6 @@ def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, . for line in lines: if not _CODE.fullmatch(line.code): raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры") - codes = [line.code for line in lines] - if len(codes) != len(set(codes)): - raise ValueError(f"редакция {edition}, форма {form}: повтор кода") return lines diff --git a/api/src/domain/statement_names.py b/api/src/domain/statement_names.py new file mode 100644 index 0000000..c03434d --- /dev/null +++ b/api/src/domain/statement_names.py @@ -0,0 +1,51 @@ +"""Совпадение наименований строки формы со справочными (05-domain-rules §11.9, спека «Совпадение наименований»).""" + +import difflib +import re +from collections.abc import Iterable + +__all__ = ["names_match", "normalize", "section_number"] + +_NON_ALNUM = re.compile(r"[\W_]+") +_TOTAL_PREFIX = "итого по разделу" +_MIN_CONTAINED = 5 +_MIN_RATIO = 0.8 +_ROMAN_LATIN = str.maketrans({"і": "i", "х": "x"}) # кириллические «і», «х» -> латиница +_ROMAN_HEAD = re.compile(r"[ivx]+") + + +def normalize(text: str) -> str: + """Нижний регистр, `ё` -> `е`, всё кроме букв и цифр — пробел, схлопывание, `strip`.""" + return _NON_ALNUM.sub(" ", text.lower().replace("ё", "е")).strip() + + +def section_number(normalized: str) -> str | None: + """Номер раздела «Итого по разделу …» (строчными буквами i, v, x) из нормализованного имени; остаток слова — + хвост OCR («v1» -> «v») — отбрасывается. Имя не «итого по разделу» или номер не выделен — `None`.""" + if not normalized.startswith(_TOTAL_PREFIX): + return None + words = normalized[len(_TOTAL_PREFIX) :].split() + if not words: + return None + head = _ROMAN_HEAD.match(words[0].translate(_ROMAN_LATIN)) + return head.group() if head else None + + +def _match_pair(a: str, b: str) -> bool: + if a.startswith(_TOTAL_PREFIX) or b.startswith(_TOTAL_PREFIX): + na, nb = section_number(a), section_number(b) + return a == b if na is None or nb is None else na == nb + if a == b: + return True + short, long_ = sorted((a, b), key=len) + if len(short) >= _MIN_CONTAINED and short in long_: + return True + return difflib.SequenceMatcher(None, a, b).ratio() >= _MIN_RATIO + + +def names_match(recognized: str | None, references: Iterable[str]) -> bool: + """Наименование совпало хотя бы с одним справочным; пустое имя или нет справочных — `False`.""" + name = normalize(recognized or "") + if not name: + return False + return any(ref and _match_pair(name, ref) for ref in map(normalize, references)) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index bd9ab93..6894416 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -140,7 +140,7 @@ class StatementService: columns, lines = await repo.load_form(self._session, meta.id) 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) + template = load_line_catalog().template_lines(edition, document.document_type.value) return schemas.statement_out( document, meta, columns, lines, report, cross, edition=edition, template=template, parents=parents ) @@ -222,10 +222,9 @@ class StatementService: lines = await repo.form_lines(self._session, meta.id) by_id = {line.id: line for line in lines} codes = {line.line_code for line in lines} - template = { - item.code: item - for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value) - } + edition = edition_for(document.reporting_date) + catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value) + template = {item.code: item for item in catalog_lines} targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = [] for cell in body.values: if cell.column_id not in columns: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 7254481..c00261e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -44,7 +44,7 @@ MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма 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н) +MEZH_BALANCE_TEMPLATE_ONLY = {"1105", "1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) MEZH_INCOME_TEMPLATE_ONLY = {"2420"} EVENTS_AFTER_BREAK_AND_ACCEPT = 2 @@ -1222,7 +1222,7 @@ async def test_patch_line_code_existing_or_unknown_rejected( client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) count = await db_line_count(seeded, document) - for code in ("1150", "1105"): # 1150 есть в форме; 1105 нет в справочнике fsbu4 + for code in ("1150", "1106"): # 1150 есть в форме; 1106 нет в справочнике fsbu4 response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py index 32b4542..83ab0f6 100644 --- a/api/tests/test_domain_statement_lines.py +++ b/api/tests/test_domain_statement_lines.py @@ -14,38 +14,74 @@ CATALOG = load_line_catalog() [ ("66n", "BALANCE", 37, "1110", "1700"), ("66n", "INCOME", 22, "2110", "2910"), - ("fsbu4", "BALANCE", 34, "1110", "1700"), - ("fsbu4", "INCOME", 23, "2110", "2910"), + ("fsbu4", "BALANCE", 38, "1105", "1700"), + ("fsbu4", "INCOME", 24, "2110", "2910"), ], ) def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None: - lines = CATALOG.lines(edition, form) + lines = CATALOG.template_lines(edition, form) assert len(lines) == count assert lines[0].code == first and lines[-1].code == last assert len({line.code for line in lines}) == count def test_names_fixed_from_templates() -> None: - names = {line.code: line.name for line in CATALOG.lines("fsbu4", "BALANCE")} + names = {line.code: line.name for line in CATALOG.template_lines("fsbu4", "BALANCE")} assert names["1110"] == "Нематериальные активы" assert names["1210"] == "Запасы" assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)" assert names["1410"] == names["1510"] == "Заемные средства" assert names["1600"] == names["1700"] == "БАЛАНС" - old = {line.code: line.name for line in CATALOG.lines("66n", "BALANCE")} + old = {line.code: line.name for line in CATALOG.template_lines("66n", "BALANCE")} assert old["1160"] == "Доходные вложения в материальные ценности" assert "1105" not in old and "1215" not in old and "1215" in names - income = {line.code: line.name for line in CATALOG.lines("fsbu4", "INCOME")} - assert "2420" in income and "2460" not in income + income = {line.code: line.name for line in CATALOG.template_lines("fsbu4", "INCOME")} + assert "2420" in income and "2460" in income assert all( - "\n" not in line.name for e in ("66n", "fsbu4") for f in ("BALANCE", "INCOME") for line in CATALOG.lines(e, f) + "\n" not in line.name + for e in ("66n", "fsbu4") + for f in ("BALANCE", "INCOME") + for line in CATALOG.template_lines(e, f) ) def test_no_edition_means_no_lines() -> None: - assert CATALOG.lines(None, "BALANCE") == () - assert CATALOG.lines("unknown", "BALANCE") == () - assert CATALOG.lines("66n", "OSV") == () + assert CATALOG.template_lines(None, "BALANCE") == () + assert CATALOG.template_lines("unknown", "BALANCE") == () + assert CATALOG.template_lines("66n", "OSV") == () + + +def test_catalog_two_names_one_template_line(tmp_path: Path) -> None: + lines = CATALOG.template_lines("fsbu4", "BALANCE") + assert [line.code for line in lines].count("1520") == 1 + assert {line.code: line.name for line in lines}["1520"] == "Кредиторская задолженность" + assert CATALOG.names("BALANCE", "1520") == ("Кредиторская задолженность", "Обязательство по аренде") + assert CATALOG.reference_name("fsbu4", "BALANCE", "1520") == "Кредиторская задолженность" + assert CATALOG.reference_name("fsbu4", "BALANCE", "9999") is None + assert "1520" in CATALOG.edition_codes("fsbu4", "BALANCE") and "9999" not in CATALOG.edition_codes("66n", "INCOME") + path = tmp_path / "statement_lines.yaml" + path.write_text( + "editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n" + " INCOME: []\n", + encoding="utf-8", + ) + own = load_line_catalog(path) + assert [line.name for line in own.template_lines("x", "BALANCE")] == ["a"] + assert own.names("BALANCE", "1110") == ("a", "b") + + +def test_catalog_fsbu4_additions() -> None: + assert "НДС, начисленный по отгрузке" in CATALOG.names("BALANCE", "1220") + assert "Налог при упрощенной системе налогообложения" in CATALOG.names("INCOME", "2410") + expected = { + "1105": "Гудвил", + "1130": "Нематериальные поисковые активы", + "1140": "Материальные поисковые активы", + "1530": "Доходы будущих периодов", + } + for code, name in expected.items(): + assert CATALOG.reference_name("fsbu4", "BALANCE", code) == name + assert CATALOG.reference_name("fsbu4", "INCOME", "2460") == "Прочее" def test_template_line_is_value_object() -> None: @@ -56,11 +92,6 @@ def test_template_line_is_value_object() -> None: ("body", "message"), [ ("editions:\n - id: x\n forms:\n BALANCE: [{code: '11', name: a}]\n INCOME: []\n", "код"), - ( - "editions:\n - id: x\n forms:\n BALANCE: [{code: '1110', name: a}, {code: '1110', name: b}]\n" - " INCOME: []\n", - "повтор", - ), ("editions:\n - id: x\n forms:\n BALANCE: []\n", "INCOME"), ("editions: [\n", "statement_lines"), ], diff --git a/api/tests/test_domain_statement_names.py b/api/tests/test_domain_statement_names.py new file mode 100644 index 0000000..3b5bb49 --- /dev/null +++ b/api/tests/test_domain_statement_names.py @@ -0,0 +1,40 @@ +"""Совпадение наименований §11.9 (спека «Совпадение наименований»).""" + +import pytest + +from src.domain.statement_names import names_match, normalize, section_number + + +@pytest.mark.parametrize( + ("recognized", "references", "expected"), + [ + ("Нематериальные активы", ["Результаты исследований и разработок"], False), + ( + "НДС, начисленный по отгрузке", + ["Налог на добавленную стоимость по приобретенным ценностям", "НДС, начисленный по отгрузке"], + True, + ), + ("Итого по разделу I", ["Итого по разделу II"], False), + ("Итого по разделу V1", ["Итого по разделу V"], True), + ("Итого по разделу V /", ["Итого по разделу V"], True), + ("Итого по разделу", ["Итого по разделу II"], False), + ("АКТИВ I. Внеоборотные активы Гудвил", ["Гудвил"], True), + ("Расчёты", ["Расчеты"], True), + ("", ["Запасы"], False), + (None, ["Запасы"], False), + ("Запасы", [], False), + ("Итого по разделу х", ["Итого по разделу X"], True), + ("Итого по разделу", ["Итого по разделу"], True), + ("Итого по разделу II", ["Итого по разделу IV"], False), + ("Запасы", ["Основные средства"], False), + ], +) +def test_names_match(recognized: str | None, references: list[str], expected: bool) -> None: + assert names_match(recognized, references) is expected + + +def test_normalize_and_section_number() -> None: + assert normalize(" Расчёты,\tс (покупателями)_ ") == "расчеты с покупателями" + assert section_number(normalize("Итого по разделу VІ")) == "vi" + assert section_number("запасы") is None + assert section_number("итого по разделу") is None