feat(domain): словарь имён с синонимами (Р-20) и совпадение наименований §11.9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:52:21 +04:00
parent 782580beb4
commit 1dc510fbb5
7 changed files with 195 additions and 34 deletions

View File

@ -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). Порядок строк в списке — порядок формы. # design D-2 change statement-template-lines). Порядок строк в списке — порядок формы.
# Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже. # Редакция выбирается по дате периода документа (edition_for): 66n — до 2024-12-31 включительно, fsbu4 — позже.
# Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после # Источник обеих редакций — шаблоны банка fixtures/statements-xlsx/templates/ («ББ/ОФР до 01.01.2025», «… после
@ -72,7 +74,10 @@ editions:
- id: fsbu4 - id: fsbu4
forms: forms:
BALANCE: BALANCE:
- {code: "1105", name: "Гудвил"}
- {code: "1110", name: "Нематериальные активы"} - {code: "1110", name: "Нематериальные активы"}
- {code: "1130", name: "Нематериальные поисковые активы"}
- {code: "1140", name: "Материальные поисковые активы"}
- {code: "1150", name: "Основные средства"} - {code: "1150", name: "Основные средства"}
- {code: "1160", name: "Инвестиционная недвижимость"} - {code: "1160", name: "Инвестиционная недвижимость"}
- {code: "1170", name: "Финансовые вложения"} - {code: "1170", name: "Финансовые вложения"}
@ -82,6 +87,7 @@ editions:
- {code: "1210", name: "Запасы"} - {code: "1210", name: "Запасы"}
- {code: "1215", name: "Долгосрочные активы к продаже"} - {code: "1215", name: "Долгосрочные активы к продаже"}
- {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"} - {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"}
- {code: "1220", name: "НДС, начисленный по отгрузке"}
- {code: "1230", name: "Дебиторская задолженность"} - {code: "1230", name: "Дебиторская задолженность"}
- {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"} - {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"}
- {code: "1250", name: "Денежные средства и денежные эквиваленты"} - {code: "1250", name: "Денежные средства и денежные эквиваленты"}
@ -102,6 +108,8 @@ editions:
- {code: "1400", name: "Итого по разделу IV"} - {code: "1400", name: "Итого по разделу IV"}
- {code: "1510", name: "Заемные средства"} - {code: "1510", name: "Заемные средства"}
- {code: "1520", name: "Кредиторская задолженность"} - {code: "1520", name: "Кредиторская задолженность"}
- {code: "1520", name: "Обязательство по аренде"}
- {code: "1530", name: "Доходы будущих периодов"}
- {code: "1540", name: "Оценочные обязательства"} - {code: "1540", name: "Оценочные обязательства"}
- {code: "1550", name: "Прочие краткосрочные обязательства"} - {code: "1550", name: "Прочие краткосрочные обязательства"}
- {code: "1500", name: "Итого по разделу V"} - {code: "1500", name: "Итого по разделу V"}
@ -122,7 +130,9 @@ editions:
- {code: "2410", name: "Налог на прибыль организаций"} - {code: "2410", name: "Налог на прибыль организаций"}
- {code: "2411", name: "текущий налог на прибыль организаций"} - {code: "2411", name: "текущий налог на прибыль организаций"}
- {code: "2412", name: "отложенный налог на прибыль организаций"} - {code: "2412", name: "отложенный налог на прибыль организаций"}
- {code: "2410", name: "Налог при упрощенной системе налогообложения"}
- {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"} - {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"}
- {code: "2460", name: "Прочее"}
- {code: "2400", name: "Чистая прибыль (убыток)"} - {code: "2400", name: "Чистая прибыль (убыток)"}
- {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"} - {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"}
- {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"} - {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"}

View File

@ -1,9 +1,10 @@
"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»; """Словарь имён строк форм ББ/ОФР по редакциям — состав формы для экрана проверки (05-domain-rules §7 «Ядро»;
change statement-template-lines, design D-2). change statement-template-lines, design D-2) и справочные имена для совпадения наименований (§11.9, Р-20).
Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Код в редакции может стоять
`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме: несколько раз (синонимы и законные повторы, Р-20): состав формы берёт первое имя, сравнение наименований — любое.
строки источника, которых в нём нет, остаются как есть. Редакцию выбирает `statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не
переименовывает в форме: строки источника, которых в нём нет, остаются как есть.
""" """
import functools import functools
@ -28,14 +29,46 @@ class TemplateLine:
@dataclass(frozen=True) @dataclass(frozen=True)
class LineCatalog: class LineCatalog:
"""`editions[редакция][форма]` — все записи словаря в порядке формы, повтор кода допустим."""
editions: dict[str, dict[str, tuple[TemplateLine, ...]]] editions: dict[str, dict[str, tuple[TemplateLine, ...]]]
def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]: def _entries(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]:
"""Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто."""
if edition is None: if edition is None:
return () return ()
return self.editions.get(edition, {}).get(form, ()) 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, ...]: def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]:
if not isinstance(items, list): if not isinstance(items, list):
@ -44,9 +77,6 @@ def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, .
for line in lines: for line in lines:
if not _CODE.fullmatch(line.code): if not _CODE.fullmatch(line.code):
raise ValueError(f"редакция {edition}, форма {form}: код {line.code!r} — не четыре цифры") 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 return lines

View File

@ -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))

View File

@ -140,7 +140,7 @@ class StatementService:
columns, lines = await repo.load_form(self._session, meta.id) columns, lines = await repo.load_form(self._session, meta.id)
report, cross, parents = 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().template_lines(edition, document.document_type.value)
return schemas.statement_out( return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition, template=template, parents=parents 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) lines = await repo.form_lines(self._session, meta.id)
by_id = {line.id: line for line in lines} by_id = {line.id: line for line in lines}
codes = {line.line_code for line in lines} codes = {line.line_code for line in lines}
template = { edition = edition_for(document.reporting_date)
item.code: item catalog_lines = load_line_catalog().template_lines(edition, document.document_type.value)
for item in load_line_catalog().lines(edition_for(document.reporting_date), document.document_type.value) template = {item.code: item for item in catalog_lines}
}
targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = [] targets: list[tuple[schemas.CellPatch, FinancialStatementLine | None, TemplateLine | None]] = []
for cell in body.values: for cell in body.values:
if cell.column_id not in columns: if cell.column_id not in columns:

View File

@ -44,7 +44,7 @@ MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма
UNAGRANDE_BALANCE = GOLDEN_STATEMENTS / "unagrande-group" / "2025-Q3" / "ББ. Лист 1 3кв.25.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 = {"1105", "1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
MEZH_INCOME_TEMPLATE_ONLY = {"2420"} MEZH_INCOME_TEMPLATE_ONLY = {"2420"}
EVENTS_AFTER_BREAK_AND_ACCEPT = 2 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) client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0) col0 = await _column(client, document.id, 0)
count = await db_line_count(seeded, document) 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( response = await client.patch(
f"/documents/{document.id}/statement", f"/documents/{document.id}/statement",
json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]},

View File

@ -14,38 +14,74 @@ CATALOG = load_line_catalog()
[ [
("66n", "BALANCE", 37, "1110", "1700"), ("66n", "BALANCE", 37, "1110", "1700"),
("66n", "INCOME", 22, "2110", "2910"), ("66n", "INCOME", 22, "2110", "2910"),
("fsbu4", "BALANCE", 34, "1110", "1700"), ("fsbu4", "BALANCE", 38, "1105", "1700"),
("fsbu4", "INCOME", 23, "2110", "2910"), ("fsbu4", "INCOME", 24, "2110", "2910"),
], ],
) )
def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None: 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 len(lines) == count
assert lines[0].code == first and lines[-1].code == last assert lines[0].code == first and lines[-1].code == last
assert len({line.code for line in lines}) == count assert len({line.code for line in lines}) == count
def test_names_fixed_from_templates() -> None: 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["1110"] == "Нематериальные активы"
assert names["1210"] == "Запасы" assert names["1210"] == "Запасы"
assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)" assert names["1310"] == "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"
assert names["1410"] == names["1510"] == "Заемные средства" assert names["1410"] == names["1510"] == "Заемные средства"
assert names["1600"] == names["1700"] == "БАЛАНС" 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 old["1160"] == "Доходные вложения в материальные ценности"
assert "1105" not in old and "1215" not in old and "1215" in names 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")} income = {line.code: line.name for line in CATALOG.template_lines("fsbu4", "INCOME")}
assert "2420" in income and "2460" not in income assert "2420" in income and "2460" in income
assert all( 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: def test_no_edition_means_no_lines() -> None:
assert CATALOG.lines(None, "BALANCE") == () assert CATALOG.template_lines(None, "BALANCE") == ()
assert CATALOG.lines("unknown", "BALANCE") == () assert CATALOG.template_lines("unknown", "BALANCE") == ()
assert CATALOG.lines("66n", "OSV") == () 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: def test_template_line_is_value_object() -> None:
@ -56,11 +92,6 @@ def test_template_line_is_value_object() -> None:
("body", "message"), ("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: '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 - id: x\n forms:\n BALANCE: []\n", "INCOME"),
("editions: [\n", "statement_lines"), ("editions: [\n", "statement_lines"),
], ],

View File

@ -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