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:
parent
782580beb4
commit
1dc510fbb5
@ -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: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
51
api/src/domain/statement_names.py
Normal file
51
api/src/domain/statement_names.py
Normal 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))
|
||||
@ -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:
|
||||
|
||||
@ -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"}]},
|
||||
|
||||
@ -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"),
|
||||
],
|
||||
|
||||
40
api/tests/test_domain_statement_names.py
Normal file
40
api/tests/test_domain_statement_names.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user