feat(statements): парсер ББ и ОФР из Excel, контракт v1.2 (Срез 2b) #16
@ -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, не код) |
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
34
api/tests/fixtures/test_golden.py
vendored
34
api/tests/fixtures/test_golden.py
vendored
@ -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) == []
|
||||
|
||||
24
api/tests/fixtures/test_rules_coverage.py
vendored
24
api/tests/fixtures/test_rules_coverage.py
vendored
@ -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)
|
||||
|
||||
52
api/tests/fixtures/test_statements_property.py
vendored
Normal file
52
api/tests/fixtures/test_statements_property.py
vendored
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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]]:
|
||||
|
||||
41
api/tests/statement_helpers.py
Normal file
41
api/tests/statement_helpers.py
Normal 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()
|
||||
64
api/tests/statements_manifest.yaml
Normal file
64
api/tests/statements_manifest.yaml
Normal 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: "шаблон"}]
|
||||
@ -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"
|
||||
|
||||
@ -8,6 +8,7 @@ EXPECTED = (
|
||||
"st03_no_codes.xlsx",
|
||||
"st04_mixed_book.xlsx",
|
||||
"st05_continuation_only.xlsx",
|
||||
"st06_header_variants.xlsx",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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 == []
|
||||
|
||||
|
||||
# --- Валидация контракта ---
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user