feat(statements): справочник строк форм ББ/ОФР по редакциям (#80)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 11:56:27 +04:00
parent 381c373467
commit 438752f035
3 changed files with 270 additions and 0 deletions

View File

@ -0,0 +1,132 @@
# Справочник строк форм ББ/ОФР по редакциям: полный состав формы для экрана проверки (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», «… после
# 01.01.2025»; решение владельца 09.10.2026, данные OQ-150 для ядра); склейки наименований с заголовками разделов и
# сносками исправлены (design R-2). Копия словаря scan-gateway (api/rules/statement_lines.yaml) с ним не совпадает:
# сведение копий — follow-up.
editions:
- id: 66n
forms:
BALANCE:
- {code: "1110", name: "Нематериальные активы"}
- {code: "1120", name: "Результаты исследований и разработок"}
- {code: "1130", name: "Нематериальные поисковые активы"}
- {code: "1140", name: "Материальные поисковые активы"}
- {code: "1150", name: "Основные средства"}
- {code: "1160", name: "Доходные вложения в материальные ценности"}
- {code: "1170", name: "Финансовые вложения"}
- {code: "1180", name: "Отложенные налоговые активы"}
- {code: "1190", name: "Прочие внеоборотные активы"}
- {code: "1100", name: "Итого по разделу I"}
- {code: "1210", name: "Запасы"}
- {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"}
- {code: "1230", name: "Дебиторская задолженность"}
- {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"}
- {code: "1250", name: "Денежные средства и денежные эквиваленты"}
- {code: "1260", name: "Прочие оборотные активы"}
- {code: "1200", name: "Итого по разделу II"}
- {code: "1600", name: "БАЛАНС"}
- {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"}
- {code: "1320", name: "Собственные акции, выкупленные у акционеров"}
- {code: "1340", name: "Переоценка внеоборотных активов"}
- {code: "1350", name: "Добавочный капитал (без переоценки)"}
- {code: "1360", name: "Резервный капитал"}
- {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}
- {code: "1300", name: "Итого по разделу III"}
- {code: "1410", name: "Заемные средства"}
- {code: "1420", name: "Отложенные налоговые обязательства"}
- {code: "1430", name: "Оценочные обязательства"}
- {code: "1450", name: "Прочие обязательства"}
- {code: "1400", name: "Итого по разделу IV"}
- {code: "1510", name: "Заемные средства"}
- {code: "1520", name: "Кредиторская задолженность"}
- {code: "1530", name: "Доходы будущих периодов"}
- {code: "1540", name: "Оценочные обязательства"}
- {code: "1550", name: "Прочие обязательства"}
- {code: "1500", name: "Итого по разделу V"}
- {code: "1700", name: "БАЛАНС"}
INCOME:
- {code: "2110", name: "Выручка"}
- {code: "2120", name: "Себестоимость продаж"}
- {code: "2100", name: "Валовая прибыль (убыток)"}
- {code: "2210", name: "Коммерческие расходы"}
- {code: "2220", name: "Управленческие расходы"}
- {code: "2200", name: "Прибыль (убыток) от продаж"}
- {code: "2310", name: "Доходы от участия в других организациях"}
- {code: "2320", name: "Проценты к получению"}
- {code: "2330", name: "Проценты к уплате"}
- {code: "2340", name: "Прочие доходы"}
- {code: "2350", name: "Прочие расходы"}
- {code: "2300", name: "Прибыль (убыток) до налогообложения"}
- {code: "2410", name: "Налог на прибыль"}
- {code: "2411", name: "в т.ч. текущий налог на прибыль"}
- {code: "2412", name: "отложенный налог на прибыль"}
- {code: "2460", name: "Прочее"}
- {code: "2400", name: "Чистая прибыль (убыток)"}
- {code: "2510", name: "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"}
- {code: "2520", name: "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"}
- {code: "2500", name: "Совокупный финансовый результат периода"}
- {code: "2900", name: "Базовая прибыль (убыток) на акцию"}
- {code: "2910", name: "Разводненная прибыль (убыток) на акцию"}
- id: fsbu4
forms:
BALANCE:
- {code: "1110", name: "Нематериальные активы"}
- {code: "1150", name: "Основные средства"}
- {code: "1160", name: "Инвестиционная недвижимость"}
- {code: "1170", name: "Финансовые вложения"}
- {code: "1180", name: "Отложенные налоговые активы"}
- {code: "1190", name: "Прочие внеоборотные активы"}
- {code: "1100", name: "Итого по разделу I"}
- {code: "1210", name: "Запасы"}
- {code: "1215", name: "Долгосрочные активы к продаже"}
- {code: "1220", name: "Налог на добавленную стоимость по приобретенным ценностям"}
- {code: "1230", name: "Дебиторская задолженность"}
- {code: "1240", name: "Финансовые вложения (за исключением денежных эквивалентов)"}
- {code: "1250", name: "Денежные средства и денежные эквиваленты"}
- {code: "1260", name: "Прочие оборотные активы"}
- {code: "1200", name: "Итого по разделу II"}
- {code: "1600", name: "БАЛАНС"}
- {code: "1310", name: "Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)"}
- {code: "1320", name: "Собственные акции, принадлежащие обществу, задолженность акционеров по оплате акций"}
- {code: "1340", name: "Накопленная дооценка внеоборотных активов"}
- {code: "1350", name: "Добавочный капитал (без накопленной дооценки)"}
- {code: "1360", name: "Резервный капитал"}
- {code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}
- {code: "1300", name: "Итого по разделу III"}
- {code: "1410", name: "Заемные средства"}
- {code: "1420", name: "Отложенные налоговые обязательства"}
- {code: "1430", name: "Оценочные обязательства"}
- {code: "1450", name: "Прочие долгосрочные обязательства"}
- {code: "1400", name: "Итого по разделу IV"}
- {code: "1510", name: "Заемные средства"}
- {code: "1520", name: "Кредиторская задолженность"}
- {code: "1540", name: "Оценочные обязательства"}
- {code: "1550", name: "Прочие краткосрочные обязательства"}
- {code: "1500", name: "Итого по разделу V"}
- {code: "1700", name: "БАЛАНС"}
INCOME:
- {code: "2110", name: "Выручка"}
- {code: "2120", name: "Себестоимость продаж"}
- {code: "2100", name: "Валовая прибыль (убыток)"}
- {code: "2210", name: "Коммерческие расходы"}
- {code: "2220", name: "Управленческие расходы"}
- {code: "2200", name: "Прибыль (убыток) от продаж"}
- {code: "2310", name: "Доходы от участия в других организациях"}
- {code: "2320", name: "Проценты к получению"}
- {code: "2330", name: "Проценты к уплате"}
- {code: "2340", name: "Прочие доходы"}
- {code: "2350", name: "Прочие расходы"}
- {code: "2300", name: "Прибыль (убыток) от продолжающейся деятельности до налогообложения"}
- {code: "2410", name: "Налог на прибыль организаций"}
- {code: "2411", name: "текущий налог на прибыль организаций"}
- {code: "2412", name: "отложенный налог на прибыль организаций"}
- {code: "2420", name: "Прибыль (убыток) от прекращаемой деятельности"}
- {code: "2400", name: "Чистая прибыль (убыток)"}
- {code: "2510", name: "Результат переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток)"}
- {code: "2520", name: "Результат прочих операций, не включаемый в чистую прибыль (убыток)"}
- {code: "2530", name: "Налог на прибыль организаций, относящийся к результатам переоценки внеоборотных активов и прочих операций, не включаемых в чистую прибыль (убыток)"}
- {code: "2500", name: "Совокупный финансовый результат"}
- {code: "2900", name: "Базовая прибыль (убыток) на акцию, руб. коп."}
- {code: "2910", name: "Разводненная прибыль (убыток) на акцию, руб. коп."}

View File

@ -0,0 +1,66 @@
"""Справочник строк форм ББ/ОФР по редакциям — полный состав формы для экрана проверки (05-domain-rules §7 «Ядро»;
change statement-template-lines, design D-2).
Данные — `api/rules/statement_lines.yaml`; порядок строк в списке — порядок формы. Редакцию выбирает
`statement_mapping.edition_for`; здесь — только состав. Справочник ничего не убирает и не переименовывает в форме:
строки источника, которых в нём нет, остаются как есть.
"""
import functools
import re
from dataclasses import dataclass
from pathlib import Path
import yaml
__all__ = ["RULES_PATH", "LineCatalog", "TemplateLine", "load_line_catalog"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_lines.yaml"
FORMS = ("BALANCE", "INCOME")
_CODE = re.compile(r"^\d{4}$")
@dataclass(frozen=True)
class TemplateLine:
code: str
name: str
@dataclass(frozen=True)
class LineCatalog:
editions: dict[str, dict[str, tuple[TemplateLine, ...]]]
def lines(self, edition: str | None, form: str) -> tuple[TemplateLine, ...]:
"""Строки формы `form` редакции `edition` в порядке формы; без редакции или для чужой формы — пусто."""
if edition is None:
return ()
return self.editions.get(edition, {}).get(form, ())
def _form_lines(edition: str, form: str, items: object) -> tuple[TemplateLine, ...]:
if not isinstance(items, list):
raise ValueError(f"редакция {edition}: нет списка строк формы {form}")
lines = tuple(TemplateLine(str(item["code"]), " ".join(str(item["name"]).split())) for item in items)
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
@functools.lru_cache(maxsize=4)
def load_line_catalog(path: Path | None = None) -> LineCatalog:
"""Справочник из yaml; любая ошибка формата — `ValueError` с именем файла."""
source = path or RULES_PATH
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
editions: dict[str, dict[str, tuple[TemplateLine, ...]]] = {}
for edition in data["editions"]:
edition_id = str(edition["id"])
forms = edition["forms"]
editions[edition_id] = {form: _form_lines(edition_id, form, forms.get(form)) for form in FORMS}
return LineCatalog(editions=editions)
except (OSError, KeyError, TypeError, AttributeError, ValueError, yaml.YAMLError) as exc:
raise ValueError(f"справочник строк {source.name}: {exc}") from exc

View File

@ -0,0 +1,72 @@
"""Справочник строк форм ББ/ОФР по редакциям (spec documents-api «Форма ББ/ОФР для экрана проверки»; design D-2)."""
from pathlib import Path
import pytest
from src.domain.statement_lines import TemplateLine, load_line_catalog
CATALOG = load_line_catalog()
@pytest.mark.parametrize(
("edition", "form", "count", "first", "last"),
[
("66n", "BALANCE", 37, "1110", "1700"),
("66n", "INCOME", 22, "2110", "2910"),
("fsbu4", "BALANCE", 34, "1110", "1700"),
("fsbu4", "INCOME", 23, "2110", "2910"),
],
)
def test_edition_composition(edition: str, form: str, count: int, first: str, last: str) -> None:
lines = CATALOG.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")}
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")}
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
assert all(
"\n" not in line.name for e in ("66n", "fsbu4") for f in ("BALANCE", "INCOME") for line in CATALOG.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") == ()
def test_template_line_is_value_object() -> None:
assert TemplateLine("1700", "БАЛАНС") == TemplateLine("1700", "БАЛАНС")
@pytest.mark.parametrize(
("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"),
],
)
def test_invalid_catalog_is_value_error(tmp_path: Path, body: str, message: str) -> None:
path = tmp_path / "statement_lines.yaml"
path.write_text(body, encoding="utf-8")
with pytest.raises(ValueError, match=message):
load_line_catalog(path)