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` — для всего прочего) |
|
| `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` — конец текста периода в строке шапки («г.», «год») |
|
| `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`) |
|
| `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_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, не код) |
|
| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) |
|
||||||
|
|
||||||
|
|||||||
@ -32,8 +32,6 @@ continuation:
|
|||||||
# Подписи колонок заголовка таблицы: ячейка целиком
|
# Подписи колонок заголовка таблицы: ячейка целиком
|
||||||
code_caption:
|
code_caption:
|
||||||
- '^код(ы)?$'
|
- '^код(ы)?$'
|
||||||
name_caption:
|
|
||||||
- 'наименование\s+показателя'
|
|
||||||
explanations_caption:
|
explanations_caption:
|
||||||
- '^пояснени'
|
- '^пояснени'
|
||||||
# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число
|
# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число
|
||||||
@ -48,8 +46,6 @@ labels:
|
|||||||
- 'идентификационный\s+номер\s+налогоплательщика'
|
- 'идентификационный\s+номер\s+налогоплательщика'
|
||||||
okpo:
|
okpo:
|
||||||
- 'по\s+окпо'
|
- 'по\s+окпо'
|
||||||
okved:
|
|
||||||
- 'по\s+оквэд'
|
|
||||||
okopf:
|
okopf:
|
||||||
- 'по\s+окопф\b(?!\s*/)'
|
- 'по\s+окопф\b(?!\s*/)'
|
||||||
okfs:
|
okfs:
|
||||||
@ -66,8 +62,6 @@ labels:
|
|||||||
- '^адрес\b'
|
- '^адрес\b'
|
||||||
date:
|
date:
|
||||||
- '^(отчетная\s+)?дата\b'
|
- '^(отчетная\s+)?дата\b'
|
||||||
okud_label:
|
|
||||||
- 'форма\s+по\s+окуд'
|
|
||||||
|
|
||||||
# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации
|
# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации
|
||||||
auditor:
|
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
|
Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
|
||||||
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
|
golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
|
||||||
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
|
`fixtures/golden/expected_osv_parse/` (зеркало `osv/`); файлы форм — `fixtures/statements-xlsx/**` (включая
|
||||||
unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
|
`synthetic/`), снимки — в `fixtures/golden/expected_statement_parse/`. Без флага снимки перезаписываются, для
|
||||||
которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
|
изменившихся печатается unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с
|
||||||
|
разбором). Снимки части, для которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных
|
||||||
|
файлов), не удаляются.
|
||||||
|
|
||||||
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
|
Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
|
||||||
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
|
и убедитесь, что каждое изменение — следствие намеренной правки парсера.
|
||||||
@ -23,6 +25,8 @@ from fixtures_helpers import ( # noqa: E402
|
|||||||
osv_dkb_golden_files,
|
osv_dkb_golden_files,
|
||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
|
statement_files,
|
||||||
|
statement_synthetic_files,
|
||||||
synthetic_files,
|
synthetic_files,
|
||||||
)
|
)
|
||||||
from golden_helpers import sync_snapshots # noqa: E402
|
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)
|
print(reason, file=sys.stderr)
|
||||||
return EXIT_NO_FIXTURES
|
return EXIT_NO_FIXTURES
|
||||||
root = resolve_fixtures_dir()
|
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)
|
report = sync_snapshots(root, files, get_parser(), write=not options.check)
|
||||||
|
|
||||||
for diff in report.diffs:
|
for diff in report.diffs:
|
||||||
|
|||||||
@ -65,7 +65,6 @@ StatementLabel = Literal[
|
|||||||
"organization",
|
"organization",
|
||||||
"inn",
|
"inn",
|
||||||
"okpo",
|
"okpo",
|
||||||
"okved",
|
|
||||||
"okopf",
|
"okopf",
|
||||||
"okfs",
|
"okfs",
|
||||||
"okopf_okfs",
|
"okopf_okfs",
|
||||||
@ -73,7 +72,6 @@ StatementLabel = Literal[
|
|||||||
"units",
|
"units",
|
||||||
"address",
|
"address",
|
||||||
"date",
|
"date",
|
||||||
"okud_label",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_ISO_CODE_LENGTH = 3
|
_ISO_CODE_LENGTH = 3
|
||||||
@ -361,7 +359,6 @@ class StatementHeaderRules(_Model):
|
|||||||
okud: dict[FormType, list[Pattern]]
|
okud: dict[FormType, list[Pattern]]
|
||||||
continuation: list[Pattern]
|
continuation: list[Pattern]
|
||||||
code_caption: list[Pattern]
|
code_caption: list[Pattern]
|
||||||
name_caption: list[Pattern]
|
|
||||||
explanations_caption: list[Pattern]
|
explanations_caption: list[Pattern]
|
||||||
column_numbering: Pattern
|
column_numbering: Pattern
|
||||||
labels: dict[StatementLabel, list[Pattern]]
|
labels: dict[StatementLabel, list[Pattern]]
|
||||||
@ -400,9 +397,6 @@ class StatementHeaderRules(_Model):
|
|||||||
def is_code_caption(self, text: str) -> bool:
|
def is_code_caption(self, text: str) -> bool:
|
||||||
return any(pattern.search(text) for pattern in self.code_caption)
|
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:
|
def is_explanations_caption(self, text: str) -> bool:
|
||||||
return any(pattern.search(text) for pattern in self.explanations_caption)
|
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)
|
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:
|
def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
|
||||||
"""Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
|
"""Лишний снимок (файл удалён или переименован) — ошибка: `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}
|
expected = {golden_path(fixtures_dir, file) for file in sources}
|
||||||
directory = fixtures_dir / "golden" / "expected_osv_parse"
|
present: set[Path] = set()
|
||||||
present = set(directory.rglob("*.json")) if directory.is_dir() else 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) == []
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files
|
from fixtures_helpers import (
|
||||||
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
|
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 import get_parser
|
||||||
from src.parser.book import BookParser
|
from src.parser.book import BookParser
|
||||||
@ -44,7 +50,13 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
|
|||||||
max_bytes=base.max_bytes,
|
max_bytes=base.max_bytes,
|
||||||
timeout_seconds=base.timeout_seconds,
|
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:
|
for file in files:
|
||||||
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
||||||
return rules, counter, len(files)
|
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:
|
def test_coverage_sees_every_dictionary_entry_and_all_files(coverage: tuple[Rules, MatchCounter, int]) -> None:
|
||||||
"""Счётчик не пуст и обход видит записи всех четырёх словарей (защита от «покрытия» на пустом месте)."""
|
"""Счётчик не пуст и обход видит записи всех семи словарей (защита от «покрытия» на пустом месте)."""
|
||||||
rules, counter, files = coverage
|
rules, counter, files = coverage
|
||||||
entries = list(iter_patterns(rules))
|
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 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)
|
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__ = [
|
__all__ = [
|
||||||
"MISSING",
|
"MISSING",
|
||||||
"SNAPSHOT_DIR",
|
"SNAPSHOT_DIR",
|
||||||
|
"STATEMENT_SNAPSHOT_DIR",
|
||||||
"SyncReport",
|
"SyncReport",
|
||||||
"VOLATILE",
|
"VOLATILE",
|
||||||
"check_snapshot",
|
"check_snapshot",
|
||||||
@ -38,12 +39,16 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
SNAPSHOT_DIR = "expected_osv_parse"
|
SNAPSHOT_DIR = "expected_osv_parse"
|
||||||
|
STATEMENT_SNAPSHOT_DIR = "expected_statement_parse"
|
||||||
|
STATEMENTS_DIR = "statements-xlsx"
|
||||||
GOLDEN_DIR = "golden"
|
GOLDEN_DIR = "golden"
|
||||||
OSV_DIR = "osv"
|
OSV_DIR = "osv"
|
||||||
OSV_DKB_DIR = "osv-dkb"
|
OSV_DKB_DIR = "osv-dkb"
|
||||||
SYNTHETIC_DIR = "synthetic"
|
SYNTHETIC_DIR = "synthetic"
|
||||||
REAL_PART = "real"
|
REAL_PART = "real"
|
||||||
DKB_PART = "dkb"
|
DKB_PART = "dkb"
|
||||||
|
STATEMENTS_PART = "statements"
|
||||||
|
STATEMENTS_SYNTHETIC_PART = "statements-synthetic"
|
||||||
JSON_INDENT = 2
|
JSON_INDENT = 2
|
||||||
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
|
||||||
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
|
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
|
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:
|
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
|
dkb = fixtures_root / OSV_DKB_DIR
|
||||||
if dkb in file.parents:
|
if dkb in file.parents:
|
||||||
return Path(OSV_DKB_DIR) / file.relative_to(dkb)
|
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:
|
def golden_path(fixtures_root: Path, file: Path) -> Path:
|
||||||
"""`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ —
|
"""`golden/expected_osv_parse/<путь файла без расширения>.json`: зеркало дерева `osv/`, а файлы пакета ДКБ —
|
||||||
в подкаталоге `osv-dkb/`."""
|
в подкаталоге `osv-dkb/`; для форм `statements-xlsx/**` — `golden/expected_statement_parse/<путь>.json`."""
|
||||||
relative = _mirrored(fixtures_root, file)
|
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]:
|
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()
|
report = SyncReport()
|
||||||
expected: set[Path] = set()
|
expected: set[Path] = set()
|
||||||
files = list(files)
|
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:
|
for file in files:
|
||||||
target = golden_path(fixtures_root, file)
|
target = golden_path(fixtures_root, file)
|
||||||
expected.add(target)
|
expected.add(target)
|
||||||
@ -164,27 +177,45 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
|
|||||||
if write:
|
if write:
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text(current, encoding="utf-8")
|
target.write_text(current, encoding="utf-8")
|
||||||
snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
|
for snapshot_dir in (SNAPSHOT_DIR, STATEMENT_SNAPSHOT_DIR):
|
||||||
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
|
snapshots = fixtures_root / GOLDEN_DIR / snapshot_dir
|
||||||
if stale in expected:
|
for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
|
||||||
continue
|
if stale in expected:
|
||||||
if _source_part(stale.relative_to(snapshots)) not in present_parts:
|
continue
|
||||||
report.protected.append(stale.relative_to(fixtures_root))
|
if _snapshot_part(snapshot_dir, stale.relative_to(snapshots)) not in present_parts:
|
||||||
continue
|
report.protected.append(stale.relative_to(fixtures_root))
|
||||||
report.orphans.append(stale.relative_to(fixtures_root))
|
continue
|
||||||
if write:
|
report.orphans.append(stale.relative_to(fixtures_root))
|
||||||
stale.unlink()
|
if write:
|
||||||
|
stale.unlink()
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _source_part(relative: Path) -> str:
|
def _file_part(fixtures_root: Path, file: Path) -> str:
|
||||||
"""Часть дерева по пути в зеркале (относительно `osv/` или каталога снимков): `synthetic`, `dkb` или `real`."""
|
"""Часть дерева файла: `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 ""
|
first = relative.parts[0] if relative.parts else ""
|
||||||
if first == SYNTHETIC_DIR:
|
if first == SYNTHETIC_DIR:
|
||||||
return SYNTHETIC_DIR
|
return SYNTHETIC_DIR
|
||||||
return DKB_PART if first == OSV_DKB_DIR else REAL_PART
|
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:
|
def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
|
||||||
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
|
"""Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
|
||||||
snapshot = golden_path(fixtures_root, file)
|
snapshot = golden_path(fixtures_root, file)
|
||||||
|
|||||||
@ -19,7 +19,12 @@ DICTIONARIES = {
|
|||||||
"groups": "osv_groups.yaml",
|
"groups": "osv_groups.yaml",
|
||||||
"periods": "periods.yaml",
|
"periods": "periods.yaml",
|
||||||
"data_types": "data_types.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]]:
|
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-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две
|
st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две
|
||||||
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
|
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
|
||||||
и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`).
|
и листом баланса; st05 — одинокая страница 2; st06 — варианты заголовков, шапки и подписей, которых нет в реальных
|
||||||
|
файлах (покрытие словарей: старое название ОФР, расшифровки, ОДДС, ОИК, целевое использование, «Пояснения»).
|
||||||
|
Детерминирован (см. `generate.py`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -139,12 +141,48 @@ def _st05() -> list[SheetSpec]:
|
|||||||
return [sheet]
|
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]]] = {
|
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
|
||||||
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
|
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
|
||||||
"st02_csv_like.xlsx": (_st02, to_xlsx),
|
"st02_csv_like.xlsx": (_st02, to_xlsx),
|
||||||
"st03_no_codes.xlsx": (_st03, to_xlsx),
|
"st03_no_codes.xlsx": (_st03, to_xlsx),
|
||||||
"st04_mixed_book.xlsx": (_st04, to_xlsx),
|
"st04_mixed_book.xlsx": (_st04, to_xlsx),
|
||||||
"st05_continuation_only.xlsx": (_st05, to_xlsx),
|
"st05_continuation_only.xlsx": (_st05, to_xlsx),
|
||||||
|
"st06_header_variants.xlsx": (_st06, to_xlsx),
|
||||||
}
|
}
|
||||||
FILE_NAMES = tuple(FILES)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -161,7 +199,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
|
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ EXPECTED = (
|
|||||||
"st03_no_codes.xlsx",
|
"st03_no_codes.xlsx",
|
||||||
"st04_mixed_book.xlsx",
|
"st04_mixed_book.xlsx",
|
||||||
"st05_continuation_only.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_STATEMENT_COUNT_TWO = 2
|
||||||
EXPECTED_COLUMN_COUNT = 2
|
EXPECTED_COLUMN_COUNT = 2
|
||||||
EXPECTED_LINES_COUNT = 0
|
EXPECTED_LINES_COUNT = 0
|
||||||
|
EXPECTED_DISCLOSURES = 5
|
||||||
|
EXPECTED_INCOME_LINES = 10
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -219,6 +221,22 @@ def test_st05_continuation_page(fixtures_dir: Path) -> None:
|
|||||||
assert len(codes) > 0, "должны быть коды"
|
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 "rules.py" in scanned
|
||||||
assert "errors.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 "grid.py" not in scanned
|
||||||
assert not any(name.startswith("readers/") for name 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 not headers.is_continuation("Бухгалтерский баланс")
|
||||||
assert headers.is_code_caption("Код") and headers.is_code_caption("Коды")
|
assert headers.is_code_caption("Код") and headers.is_code_caption("Коды")
|
||||||
assert not headers.is_code_caption("Код строки по ОКУД 0710001")
|
assert not headers.is_code_caption("Код строки по ОКУД 0710001")
|
||||||
assert headers.is_name_caption("Наименование показателя")
|
|
||||||
assert headers.is_explanations_caption("Пояснения")
|
assert headers.is_explanations_caption("Пояснения")
|
||||||
assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)}
|
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
|
assert headers.code_min_share == _CODE_MIN_SHARE and headers.min_codes == _MIN_CODES
|
||||||
|
|||||||
@ -3,7 +3,14 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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:
|
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")
|
golden_path(tmp_path, tmp_path / "osv" / "rb" / "a.xlsx")
|
||||||
== tmp_path / "golden" / SNAPSHOT_DIR / "rb" / "a.json"
|
== 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()
|
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:
|
def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
|
||||||
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
|
"""Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
|
||||||
file = files_of(root)[0]
|
file = files_of(root)[0]
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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
|
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))
|
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}
|
keys = {(file_name, path) for file_name, path, _ in entries}
|
||||||
assert ("osv_headers.yaml", "sheet_markers[0]") in keys
|
assert ("osv_headers.yaml", "sheet_markers[0]") in keys
|
||||||
assert ("osv_headers.yaml", "groups.opening[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", "rules[0].pattern") in keys
|
||||||
assert ("periods.yaml", "year") in keys
|
assert ("periods.yaml", "year") in keys
|
||||||
assert ("data_types.yaml", "currency_aliases.RUB[0]") 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 — значения, не синонимы
|
# R-22: перечень кодов ISO — значения, не синонимы
|
||||||
assert not any("currency_codes" in path for _, path, _ in entries)
|
assert not any("currency_codes" in path for _, path, _ in entries)
|
||||||
assert all(isinstance(pattern, Pattern) for _, _, pattern in entries)
|
assert all(isinstance(pattern, Pattern) for _, _, pattern in entries)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user