feat(statements): парсер ББ и ОФР из Excel, контракт v1.2 (Срез 2b) #16

Merged
aep merged 43 commits from feat/14-slice-2b-statement-parser into main 2026-10-06 21:38:00 +03:00
19 changed files with 380 additions and 50 deletions
Showing only changes of commit 07d57cbd07 - Show all commits

View File

@ -14,7 +14,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `name_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okved`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`, `okud_label`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
| `statement_periods.yaml` | `months` (названия месяцев, группы `m01…m12`), `on_date` (дата вида «на 31 декабря 2024 г.», группы `d`, `m`, `y`), `numeric_date` (дата вида «31.12.2024», группы `d`, `m`, `y`), `for_months` (период вида «за 9 месяцев 2025 г.», группы `n`, `y`), `for_range` (диапазон месяцев вида «за январь–сентябрь 2025 г.», группы `m`, `y`), `for_year` (год вида «за 2024 г.» или «2024») | текст заголовка колонки или подзаголовка формы ББ/ОФР -> дата конца периода (§12 п. 4) |
| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) |

View File

@ -32,8 +32,6 @@ continuation:
# Подписи колонок заголовка таблицы: ячейка целиком
code_caption:
- '^код(ы)?$'
name_caption:
- 'наименование\s+показателя'
explanations_caption:
- '^пояснени'
# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число
@ -48,8 +46,6 @@ labels:
- 'идентификационный\s+номер\s+налогоплательщика'
okpo:
- 'по\s+окпо'
okved:
- 'по\s+оквэд'
okopf:
- 'по\s+окопф\b(?!\s*/)'
okfs:
@ -66,8 +62,6 @@ labels:
- '^адрес\b'
date:
- '^(отчетная\s+)?дата\b'
okud_label:
- 'форма\s+по\s+окуд'
# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации
auditor:

View File

@ -1,10 +1,12 @@
"""Перезапись golden-снимков разбора ОСВ и diff изменившихся (design D-10).
"""Перезапись golden-снимков разбора ОСВ и форм и diff изменившихся (design D-10).
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`); файлы форм — `fixtures/statements-xlsx/**` (включая
`synthetic/`), снимки — в `fixtures/golden/expected_statement_parse/`. Без флага снимки перезаписываются, для
изменившихся печатается unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с
разбором). Снимки части, для которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных
файлов), не удаляются.
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
@ -23,6 +25,8 @@ from fixtures_helpers import ( # noqa: E402
osv_dkb_golden_files,
osv_fixture_files,
resolve_fixtures_dir,
statement_files,
statement_synthetic_files,
synthetic_files,
)
from golden_helpers import sync_snapshots # noqa: E402
@ -44,7 +48,13 @@ def main(argv: Sequence[str] | None = None) -> int:
print(reason, file=sys.stderr)
return EXIT_NO_FIXTURES
root = resolve_fixtures_dir()
files = [*osv_fixture_files(root), *osv_dkb_golden_files(root), *synthetic_files(root)]
files = [
*osv_fixture_files(root),
*osv_dkb_golden_files(root),
*synthetic_files(root),
*statement_files(root),
*statement_synthetic_files(root),
]
report = sync_snapshots(root, files, get_parser(), write=not options.check)
for diff in report.diffs:

View File

@ -65,7 +65,6 @@ StatementLabel = Literal[
"organization",
"inn",
"okpo",
"okved",
"okopf",
"okfs",
"okopf_okfs",
@ -73,7 +72,6 @@ StatementLabel = Literal[
"units",
"address",
"date",
"okud_label",
]
_ISO_CODE_LENGTH = 3
@ -361,7 +359,6 @@ class StatementHeaderRules(_Model):
okud: dict[FormType, list[Pattern]]
continuation: list[Pattern]
code_caption: list[Pattern]
name_caption: list[Pattern]
explanations_caption: list[Pattern]
column_numbering: Pattern
labels: dict[StatementLabel, list[Pattern]]
@ -400,9 +397,6 @@ class StatementHeaderRules(_Model):
def is_code_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.code_caption)
def is_name_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.name_caption)
def is_explanations_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.explanations_caption)

View File

@ -34,13 +34,39 @@ def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_fi
check_snapshot(fixtures_dir, synthetic_file, parser)
def test_statement_file_matches_golden_snapshot(fixtures_dir: Path, statement_file: Path, parser: Parser) -> None:
"""Реальные формы `statements-xlsx/**`: снимки в `golden/expected_statement_parse/`."""
check_snapshot(fixtures_dir, statement_file, parser)
def test_statement_synthetic_matches_golden_snapshot(
fixtures_dir: Path, statement_synthetic_file: Path, parser: Parser
) -> None:
check_snapshot(fixtures_dir, statement_synthetic_file, parser)
def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
"""Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
from fixtures_helpers import osv_dkb_golden_files, osv_fixture_files, synthetic_files # noqa: PLC0415 - один тест
from fixtures_helpers import ( # noqa: PLC0415 - один тест
osv_dkb_golden_files,
osv_fixture_files,
statement_files,
statement_synthetic_files,
synthetic_files,
)
sources = [*osv_fixture_files(fixtures_dir), *osv_dkb_golden_files(fixtures_dir), *synthetic_files(fixtures_dir)]
sources = [
*osv_fixture_files(fixtures_dir),
*osv_dkb_golden_files(fixtures_dir),
*synthetic_files(fixtures_dir),
*statement_files(fixtures_dir),
*statement_synthetic_files(fixtures_dir),
]
expected = {golden_path(fixtures_dir, file) for file in sources}
directory = fixtures_dir / "golden" / "expected_osv_parse"
present = set(directory.rglob("*.json")) if directory.is_dir() else set()
present: set[Path] = set()
for name in ("expected_osv_parse", "expected_statement_parse"):
directory = fixtures_dir / "golden" / name
if directory.is_dir():
present |= set(directory.rglob("*.json"))
assert sorted(path.relative_to(fixtures_dir).as_posix() for path in present - expected) == []

View File

@ -16,8 +16,14 @@
from pathlib import Path
import pytest
from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from fixtures_helpers import (
osv_dkb_files,
osv_fixture_files,
statement_files,
statement_synthetic_files,
synthetic_files,
)
from rules_coverage import DICTIONARIES, PATTERNLESS, MatchCounter, iter_patterns, uncovered
from src.parser import get_parser
from src.parser.book import BookParser
@ -44,7 +50,13 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
max_bytes=base.max_bytes,
timeout_seconds=base.timeout_seconds,
)
files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)]
files = [
*osv_fixture_files(fixtures_dir),
*osv_dkb_files(fixtures_dir),
*synthetic_files(fixtures_dir),
*statement_files(fixtures_dir),
*statement_synthetic_files(fixtures_dir),
]
for file in files:
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
return rules, counter, len(files)
@ -64,11 +76,11 @@ def test_every_dictionary_entry_fires_on_fixtures_and_synthetic(coverage: tuple[
def test_coverage_sees_every_dictionary_entry_and_all_files(coverage: tuple[Rules, MatchCounter, int]) -> None:
"""Счётчик не пуст и обход видит записи всех четырёх словарей (защита от «покрытия» на пустом месте)."""
"""Счётчик не пуст и обход видит записи всех семи словарей (защита от «покрытия» на пустом месте)."""
rules, counter, files = coverage
entries = list(iter_patterns(rules))
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) - PATTERNLESS
assert sum(counter.hits.values()) > len(entries)
assert files >= 72 # noqa: PLR2004 - реальные ОСВ из fixtures
assert files >= 95 # noqa: PLR2004 - реальные ОСВ, формы и синтетика из fixtures
assert not any(path.startswith("currency_codes") for _, path, _ in entries)

View File

@ -0,0 +1,52 @@
"""Инварианты форм на всех `../fixtures/statements-xlsx/**` по манифесту (spec «Словари форм, координаты, golden»)."""
import time
from pathlib import Path
from typing import cast
import pytest
from fixtures_helpers import statement_files
from result_contract import assert_valid_result
from statement_helpers import load_manifest, relative_key
from src.contract import FileType, ResultStatus, StatementDocument
from src.parser import get_parser
pytestmark = pytest.mark.fixtures
EXPECTED_FILES = 18
MAX_PARSE_SECONDS = 5
def test_manifest_covers_every_file(fixtures_dir: Path) -> None:
files = statement_files(fixtures_dir)
assert len(files) == EXPECTED_FILES
assert {relative_key(fixtures_dir, path) for path in files} == set(load_manifest())
def test_statement_file_matches_manifest(fixtures_dir: Path, statement_file: Path) -> None:
expectation = load_manifest()[relative_key(fixtures_dir, statement_file)]
started = time.monotonic()
file_type = cast(FileType, statement_file.suffix.lstrip(".").upper())
outcome = get_parser().parse(statement_file.read_bytes(), file_type)
assert time.monotonic() - started < MAX_PARSE_SECONDS
assert outcome.error is None, outcome.error
documents = [document for document in outcome.documents if isinstance(document, StatementDocument)]
assert [document.document_type for document in documents] == [item.type for item in expectation.documents]
all_done = all(item.status == "done" for item in expectation.documents)
assert outcome.status is (ResultStatus.DONE if all_done else ResultStatus.NEEDS_CHECK)
for document, item in zip(documents, expectation.documents, strict=True):
codes = [line.line_code for line in document.lines]
assert len(codes) == len(set(codes)), "коды уникальны"
assert all(value.column_index < len(document.columns) for line in document.lines for value in line.values)
if item.sheets:
assert document.locator.count(",") == item.sheets - 1
if item.complete:
assert document.checks is not None
assert document.checks.relations_failed == [], document.checks
assert document.columns
assert all(column.period_date is not None for column in document.columns)
if expectation.organization and document.header.organization_name is not None:
assert document.header.organization_name == expectation.organization
if expectation.inn and document.document_type not in expectation.inn_optional_for and document.header.inn:
assert document.header.inn == expectation.inn
assert_valid_result(outcome)

View File

@ -26,6 +26,7 @@ from src.parser.cli import STUB_ID, _error_payload, _file_type
__all__ = [
"MISSING",
"SNAPSHOT_DIR",
"STATEMENT_SNAPSHOT_DIR",
"SyncReport",
"VOLATILE",
"check_snapshot",
@ -38,12 +39,16 @@ __all__ = [
]
SNAPSHOT_DIR = "expected_osv_parse"
STATEMENT_SNAPSHOT_DIR = "expected_statement_parse"
STATEMENTS_DIR = "statements-xlsx"
GOLDEN_DIR = "golden"
OSV_DIR = "osv"
OSV_DKB_DIR = "osv-dkb"
SYNTHETIC_DIR = "synthetic"
REAL_PART = "real"
DKB_PART = "dkb"
STATEMENTS_PART = "statements"
STATEMENTS_SYNTHETIC_PART = "statements-synthetic"
JSON_INDENT = 2
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
@ -65,8 +70,15 @@ def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
return normalized
def _is_statement(fixtures_root: Path, file: Path) -> bool:
return fixtures_root / STATEMENTS_DIR in file.parents
def _mirrored(fixtures_root: Path, file: Path) -> Path:
"""Путь файла в зеркале снимков: относительно `osv/`, а для пакета ДКБ — `osv-dkb/<путь внутри osv-dkb>`."""
"""Путь файла в зеркале снимков: для форм — относительно `statements-xlsx/`, для пакета ДКБ —
`osv-dkb/<путь внутри osv-dkb>`, иначе относительно `osv/`."""
if _is_statement(fixtures_root, file):
return file.relative_to(fixtures_root / STATEMENTS_DIR)
dkb = fixtures_root / OSV_DKB_DIR
if dkb in file.parents:
return Path(OSV_DKB_DIR) / file.relative_to(dkb)
@ -75,9 +87,10 @@ def _mirrored(fixtures_root: Path, file: Path) -> Path:
def golden_path(fixtures_root: Path, file: Path) -> Path:
"""`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ —
в подкаталоге `osv-dkb/`."""
в подкаталоге `osv-dkb/`; для форм `statements-xlsx/**` — `golden/expected_statement_parse/<путь>.json`."""
relative = _mirrored(fixtures_root, file)
return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json"
snapshot_dir = STATEMENT_SNAPSHOT_DIR if _is_statement(fixtures_root, file) else SNAPSHOT_DIR
return fixtures_root / GOLDEN_DIR / snapshot_dir / relative.parent / f"{relative.stem}.json"
def render(parser: Parser, file: Path) -> dict[str, Any]:
@ -145,7 +158,7 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
report = SyncReport()
expected: set[Path] = set()
files = list(files)
present_parts = {_source_part(_mirrored(fixtures_root, file)) for file in files}
present_parts = {_file_part(fixtures_root, file) for file in files}
for file in files:
target = golden_path(fixtures_root, file)
expected.add(target)
@ -164,27 +177,45 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
if write:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(current, encoding="utf-8")
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
if stale in expected:
continue
if _source_part(stale.relative_to(snapshots)) not in present_parts:
report.protected.append(stale.relative_to(fixtures_root))
continue
report.orphans.append(stale.relative_to(fixtures_root))
if write:
stale.unlink()
for snapshot_dir in (SNAPSHOT_DIR, STATEMENT_SNAPSHOT_DIR):
snapshots = fixtures_root / GOLDEN_DIR / snapshot_dir
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
if stale in expected:
continue
if _snapshot_part(snapshot_dir, stale.relative_to(snapshots)) not in present_parts:
report.protected.append(stale.relative_to(fixtures_root))
continue
report.orphans.append(stale.relative_to(fixtures_root))
if write:
stale.unlink()
return report
def _source_part(relative: Path) -> str:
"""Часть дерева по пути в зеркале (относительно `osv/` или каталога снимков): `synthetic`, `dkb` или `real`."""
def _file_part(fixtures_root: Path, file: Path) -> str:
"""Часть дерева файла: `synthetic`, `dkb`, `real` (ОСВ), `statements` или `statements-synthetic` (формы)."""
mirrored = _mirrored(fixtures_root, file)
if _is_statement(fixtures_root, file):
return _statement_part(mirrored)
return _osv_part(mirrored)
def _snapshot_part(snapshot_dir: str, relative: Path) -> str:
"""Часть дерева по пути снимка относительно его каталога (`expected_osv_parse` или `expected_statement_parse`)."""
return _statement_part(relative) if snapshot_dir == STATEMENT_SNAPSHOT_DIR else _osv_part(relative)
def _osv_part(relative: Path) -> str:
first = relative.parts[0] if relative.parts else ""
if first == SYNTHETIC_DIR:
return SYNTHETIC_DIR
return DKB_PART if first == OSV_DKB_DIR else REAL_PART
def _statement_part(relative: Path) -> str:
first = relative.parts[0] if relative.parts else ""
return STATEMENTS_SYNTHETIC_PART if first == SYNTHETIC_DIR else STATEMENTS_PART
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
snapshot = golden_path(fixtures_root, file)

View File

@ -19,7 +19,12 @@ DICTIONARIES = {
"groups": "osv_groups.yaml",
"periods": "periods.yaml",
"data_types": "data_types.yaml",
"statement_headers": "statement_headers.yaml",
"statement_periods": "statement_periods.yaml",
"statement_checks": "statement_checks.yaml",
}
# Словари без записей распознавания: соотношения §7 — выражения над кодами, а не `Pattern` (в покрытии не участвуют)
PATTERNLESS = frozenset({"statement_checks.yaml"})
def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]:

View File

@ -0,0 +1,41 @@
"""Манифест ожиданий по реальным формам `fixtures/statements-xlsx/**` (`statements_manifest.yaml`)."""
from functools import cache
from pathlib import Path
from typing import Literal
import yaml
from fixtures_helpers import STATEMENTS_DIR
from pydantic import BaseModel, ConfigDict
MANIFEST_PATH = Path(__file__).resolve().parent / "statements_manifest.yaml"
class DocumentExpectation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
type: Literal["BALANCE", "INCOME", "DISCLOSURE"]
status: Literal["done", "needs_check"]
complete: bool
reason: str | None = None
sheets: int | None = None
class Expectation(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
organization: str | None = None
inn: str | None = None
inn_optional_for: tuple[str, ...] = ()
documents: list[DocumentExpectation]
@cache
def load_manifest() -> dict[str, Expectation]:
"""Ожидания по файлам; ключ — путь относительно `statements-xlsx/` (posix)."""
raw = yaml.safe_load(MANIFEST_PATH.read_text(encoding="utf-8"))
return {key: Expectation.model_validate(value) for key, value in raw["files"].items()}
def relative_key(fixtures_dir: Path, path: Path) -> str:
return path.relative_to(fixtures_dir / STATEMENTS_DIR).as_posix()

View File

@ -0,0 +1,64 @@
# Ожидания по реальным формам fixtures/statements-xlsx/** (README там же). Ключ — путь относительно statements-xlsx.
# documents — по порядку документов результата: type, status (done | needs_check), complete (вся форма: §7 сходится).
# Причина needs_check — данные файла (страница без шапки, шаблон без сумм, рубли при шапке «тыс. руб.»), не дефект разбора.
# Значения сверены прогоном парсера на файлах (R-13, OQ-137): расхождения с первоначальным планом помечены «по факту».
files:
"unagrande-co/2024-FY/Б.Б Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697"
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив: 1600 = 1700 не сходится"}]
"unagrande-co/2024-FY/Б.Б. Лист 2.xls":
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}]
"unagrande-co/2024-FY/ОФР. Лист 1.xls":
organization: 'Акционерное общество "Унагранде Компани"'
inn: "3249004697"
documents:
- {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., итоги 2100/2200/2300 округлены до тысяч и расходятся с суммой частей (OQ-137)"}
"unagrande-co/2024-FY/ОФР. Лист 2 .xls":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}]
"unagrande-group/2024-FY/ББ. Лист 1 .xls":
organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141"
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}]
"unagrande-group/2024-FY/ББ. Лист 2 (2).xls":
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}]
"unagrande-group/2024-FY/ОФР. Лист 2.xls":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки, стр. 1 в пакете нет"}]
"unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls":
organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141"
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "только актив"}]
"unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls":
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "страница 2 без шапки"}]
"unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls":
organization: 'Акционерное общество "Унагранде Групп"'
inn: "7728757141"
documents:
- {type: INCOME, status: needs_check, complete: false, reason: "по факту: значения в рублях с копейками при шапке тыс. руб., 2100 расходится на 17.50/9.43 руб., 2200/2300 — округление итогов до тысяч (OQ-137)"}
"unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "страница 2 без шапки"}]
"npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx":
organization: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'
inn: "2315995868"
inn_optional_for: [INCOME]
documents:
- {type: BALANCE, status: needs_check, complete: false, sheets: 5, reason: "1600 = 1700 расходится на 1.94/9.02/2.48 руб.: значения в рублях при шапке тыс. руб. (OQ-137)"}
- {type: INCOME, status: needs_check, complete: false, sheets: 2, reason: "по факту: рубли с копейками при шапке тыс. руб., 2100/2200/2300 расходятся с округлёнными итогами (OQ-137); одностроечный лист продолжения с 2400 пропущен: меньше min_codes"}
"mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635"
documents: [{type: BALANCE, status: done, complete: true}]
"mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx":
organization: 'ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"'
inn: "7016006635"
documents: [{type: INCOME, status: done, complete: true}]
"templates/ББ до 01.01.2025.xlsx":
documents:
- {type: BALANCE, status: needs_check, complete: false, reason: "шаблон без сумм и шапки"}
- {type: DISCLOSURE, status: done, complete: false, reason: "по факту: «Примечания» шаблона образуют пустой документ DISCLOSURE"}
"templates/ББ после 01.01.2025.xlsx":
documents: [{type: BALANCE, status: needs_check, complete: false, reason: "шаблон"}]
"templates/ОФР_до 01.01.2025.xlsx":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}]
"templates/ОФР_после 01.01.2025.xlsx":
documents: [{type: INCOME, status: needs_check, complete: false, reason: "шаблон"}]

View File

@ -2,7 +2,9 @@
st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`).
и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных
файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»).
Детерминирован (см. `generate.py`).
"""
import argparse
@ -139,12 +141,48 @@ def _st05() -> list[SheetSpec]:
return [sheet]
DISCLOSURE_TITLES = (
"Расшифровка дебиторской задолженности",
"Отчет об изменениях капитала",
"Отчет о движении денежных средств",
"Отчет о целевом использовании средств",
)
ST06_OKUD_ONLY = "0710005" # прочие формы 0710xxx — расшифровки (design D-3, решение №2)
def _st06() -> list[SheetSpec]:
income = SheetSpec("Прибыли")
income.put(1, 2, "Отчет о прибылях и убытках")
income.put(2, 2, "«Синтетика-Групп»") # название без правовой формы и прямых кавычек: найдётся по «
income.put(3, 1, "Организация") # значения правее и ниже нет: название берётся из строки выше
income.put(5, 1, "Адрес")
income.put(5, 3, "г. Москва, ул. Синтетическая, д. 1")
income.put(6, 1, "Единица измерения:")
income.put(6, 3, "тыс. руб.")
header_row = 8
_table(income, header_row, ["31.12.2024", "31.12.2023"], INCOME_LINES)
income.put(header_row, 3, "Пояснения")
# длинный текст в колонке «Пояснения»: без подписи она сошла бы за колонку названий (columns._name_column)
income.put(header_row + 1, 3, "См. подробные пояснения к выручке в составе раздела пятого")
sheets = [income]
for title in DISCLOSURE_TITLES:
sheet = SheetSpec(title[:25])
sheet.put(1, 2, title)
sheets.append(sheet)
okud_only = SheetSpec("Прочая форма")
okud_only.put(1, 2, "Дополнительные сведения")
okud_only.put(2, 6, "Форма по ОКУД")
okud_only.put(2, 7, ST06_OKUD_ONLY)
return [*sheets, okud_only]
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
"st02_csv_like.xlsx": (_st02, to_xlsx),
"st03_no_codes.xlsx": (_st03, to_xlsx),
"st04_mixed_book.xlsx": (_st04, to_xlsx),
"st05_continuation_only.xlsx": (_st05, to_xlsx),
"st06_header_variants.xlsx": (_st06, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -161,7 +199,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st05."
prog="python -m tests.synthetic.generate_statements", description="Синтетические формы st01…st06."
)
parser.add_argument(
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"

View File

@ -8,6 +8,7 @@ EXPECTED = (
"st03_no_codes.xlsx",
"st04_mixed_book.xlsx",
"st05_continuation_only.xlsx",
"st06_header_variants.xlsx",
)

View File

@ -25,6 +25,8 @@ SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic"
EXPECTED_STATEMENT_COUNT_TWO = 2
EXPECTED_COLUMN_COUNT = 2
EXPECTED_LINES_COUNT = 0
EXPECTED_DISCLOSURES = 5
EXPECTED_INCOME_LINES = 10
@pytest.fixture(autouse=True)
@ -219,6 +221,22 @@ def test_st05_continuation_page(fixtures_dir: Path) -> None:
assert len(codes) > 0, "должны быть коды"
def test_st06_header_variants_documents_and_header(fixtures_dir: Path) -> None:
"""st06: старое название ОФР, четыре вида расшифровок и форма только с ОКУД 0710xxx; шапка с «», «Адрес»."""
outcome = parse_named(fixtures_dir, "st06_header_variants.xlsx")
documents = statement_documents(outcome)
assert [doc.document_type for doc in documents] == ["INCOME", *["DISCLOSURE"] * EXPECTED_DISCLOSURES]
income = documents[0]
assert income.header.organization_name == "«Синтетика-Групп»"
assert income.header.address == "г. Москва, ул. Синтетическая, д. 1"
assert len(income.lines) == EXPECTED_INCOME_LINES
assert [column.period_date for column in income.columns] == [date(2024, 12, 31), date(2023, 12, 31)]
assert all(doc.lines == [] for doc in documents[1:])
assert income.checks is not None
assert income.checks.relations_failed == []
# --- Валидация контракта ---

View File

@ -201,6 +201,8 @@ def test_linter_scans_parser_modules_and_skips_grid_and_readers() -> None:
assert "rules.py" in scanned
assert "errors.py" in scanned
assert "book.py" in scanned
assert "statements/columns.py" in scanned
assert "grid.py" not in scanned
assert not any(name.startswith("readers/") for name in scanned)

View File

@ -55,7 +55,6 @@ def test_continuation_marker_and_captions(rules: Rules) -> None:
assert not headers.is_continuation("Бухгалтерский баланс")
assert headers.is_code_caption("Код") and headers.is_code_caption("Коды")
assert not headers.is_code_caption("Код строки по ОКУД 0710001")
assert headers.is_name_caption("Наименование показателя")
assert headers.is_explanations_caption("Пояснения")
assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)}
assert headers.code_min_share == _CODE_MIN_SHARE and headers.min_codes == _MIN_CODES

View File

@ -3,7 +3,14 @@
import json
from pathlib import Path
from golden_helpers import SNAPSHOT_DIR, golden_path, normalize_result, snapshot_text, unified_diff
from golden_helpers import (
SNAPSHOT_DIR,
STATEMENT_SNAPSHOT_DIR,
golden_path,
normalize_result,
snapshot_text,
unified_diff,
)
def _result() -> dict:
@ -87,3 +94,12 @@ def test_dkb_snapshot_path_mirrors_osv_dkb_under_its_own_subdirectory(tmp_path:
golden_path(tmp_path, tmp_path / "osv" / "rb" / "a.xlsx")
== tmp_path / "golden" / SNAPSHOT_DIR / "rb" / "a.json"
)
def test_statement_snapshot_path_mirrors_statements_tree_under_its_own_directory(tmp_path: Path) -> None:
file = tmp_path / "statements-xlsx" / "npz" / "2025-Q3" / "Отчётность (НПЗ).xlsx"
synthetic = tmp_path / "statements-xlsx" / "synthetic" / "st01.xlsx"
expected = tmp_path / "golden" / STATEMENT_SNAPSHOT_DIR / "npz" / "2025-Q3" / "Отчётность (НПЗ).json"
assert golden_path(tmp_path, file) == expected
assert golden_path(tmp_path, synthetic) == tmp_path / "golden" / STATEMENT_SNAPSHOT_DIR / "synthetic" / "st01.json"

View File

@ -132,6 +132,31 @@ def test_orphan_is_removed_only_within_a_present_source_directory(root: Path) ->
assert real_snapshot.is_file()
def test_statement_snapshots_are_kept_without_statement_sources_and_removed_with_them(root: Path) -> None:
"""Снимки форм (`expected_statement_parse`) лишние только там, где есть исходные файлы форм своей части."""
parser = get_parser()
kept = root / "golden" / "expected_statement_parse" / "npz" / "2025-Q3" / "книга.json"
kept.parent.mkdir(parents=True)
kept.write_text("{}\n", encoding="utf-8")
report = sync_snapshots(root, files_of(root), parser, write=True)
assert report.orphans == []
assert report.protected == [kept.relative_to(root)]
assert kept.is_file()
form = root / "statements-xlsx" / "templates" / "ББ.xlsx"
form.parent.mkdir(parents=True)
form.write_bytes(files_of(root)[0].read_bytes())
report = sync_snapshots(root, [*files_of(root), form], parser, write=False)
assert report.orphans == [kept.relative_to(root)]
assert report.protected == []
assert [path for path in report.created if "expected_statement_parse" in path.parts] == [
golden_path(root, form).relative_to(root)
]
def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
file = files_of(root)[0]

View File

@ -4,7 +4,7 @@ import shutil
from pathlib import Path
import pytest
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from rules_coverage import DICTIONARIES, PATTERNLESS, MatchCounter, iter_patterns, uncovered
from src.parser.rules import DEFAULT_RULES_DIR, Pattern, load_rules
@ -14,7 +14,7 @@ def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() ->
entries = list(iter_patterns(rules))
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values()) - PATTERNLESS
keys = {(file_name, path) for file_name, path, _ in entries}
assert ("osv_headers.yaml", "sheet_markers[0]") in keys
assert ("osv_headers.yaml", "groups.opening[0]") in keys
@ -22,6 +22,8 @@ def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() ->
assert ("periods.yaml", "rules[0].pattern") in keys
assert ("periods.yaml", "year") in keys
assert ("data_types.yaml", "currency_aliases.RUB[0]") in keys
assert ("statement_headers.yaml", "form_titles.BALANCE[0]") in keys
assert ("statement_periods.yaml", "numeric_date") in keys
# R-22: перечень кодов ISO — значения, не синонимы
assert not any("currency_codes" in path for _, path, _ in entries)
assert all(isinstance(pattern, Pattern) for _, _, pattern in entries)