diff --git a/api/rules/statement_lines.yaml b/api/rules/statement_lines.yaml new file mode 100644 index 0000000..ac0e1be --- /dev/null +++ b/api/rules/statement_lines.yaml @@ -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: "Разводненная прибыль (убыток) на акцию, руб. коп."} diff --git a/api/src/domain/statement_lines.py b/api/src/domain/statement_lines.py new file mode 100644 index 0000000..d1ef2a4 --- /dev/null +++ b/api/src/domain/statement_lines.py @@ -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 diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py new file mode 100644 index 0000000..32b4542 --- /dev/null +++ b/api/tests/test_domain_statement_lines.py @@ -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)