docs(statements): README и CLAUDE.md — парсер форм; интеграционный тест книги НПЗ через воркер

Тест ожидает needs_check (рубли при шапке «тыс. руб.», OQ-137).

Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 21:11:36 +04:00
parent 6cd2ea8a7a
commit 3a370d4872
3 changed files with 129 additions and 4 deletions

View File

@ -92,3 +92,26 @@ property, покрытие словарей); без каталога `../fixtur
с compose и сборка образа).
Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.
## Добавлено в Срезе 2b (парсер ББ/ОФР; `openspec/changes/slice-2b-statement-parser/`)
**Модули:** `api/src/parser/book.py` (`BookParser`: лимиты, чтение, дедлайн, вид листа, статус и карта ошибок; документы —
ОСВ в порядке листов, затем формы) и `api/src/parser/statements/` (`detect` → `columns` → `header` → `lines` → `checks` →
`merge`/`statement`; подробности — `README.md`, раздел «Парсер форм (ББ/ОФР)»). `get_parser(settings)` теперь создаёт
`BookParser` (листы ОСВ по-прежнему разбирает `osv.py`).
**Правила расширения форм:**
- Новая форма или написание = строка в `api/rules/statement_*.yaml` (+ синтетика `st0x` в
`tests/synthetic/generate_statements.py` или реальный файл в `../fixtures/statements-xlsx/`). Не `if` под файл,
организацию или лист. Соотношения §7 — данные `statement_checks.yaml`.
- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`): тот же AST-линтер `test_no_coordinates.py`; слов формы в
коде нет — только в YAML. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется.
- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`) под формы не правятся.
**Тесты на реальных данных:** манифест ожиданий — `api/tests/statements_manifest.yaml` (18 файлов). Golden — два каталога
в `../fixtures/golden/`: `expected_osv_parse` и `expected_statement_parse`; правятся только `make golden-update` с
просмотром diff и отдельным коммитом в `fixtures`.
**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`:
`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Интеграционный тест книги НПЗ ожидает
`needs_check` (рубли при шапке «тыс. руб.», OQ-137), а не `done`.

View File

@ -139,7 +139,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|---|---|
| `done` | контрольные суммы §1.4 сошлись на всех листах |
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
| `error` / `INVALID_DOCUMENT` | в книге нет листов ОСВ и форм (в сообщении — число просмотренных листов) |
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
@ -147,7 +147,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
(`skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
### Контрольные суммы (§1.4)
@ -247,6 +247,68 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же —
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
## Парсер форм (ББ/ОФР)
Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через `statements/`. Документы
результата: сначала ОСВ в порядке листов, затем формы в порядке листов. Структура ищется по содержимому, как в ОСВ;
синонимы и соотношения §7 — данные `api/rules/statement_*.yaml`, в коде нет ни слова из формы и ни одной координаты.
| Модуль (`api/src/parser/statements/`) | Что делает |
|---|---|
| `detect.py` | блоки форм на листе и вид листа (форма / продолжение / прочее); заголовок формы или код ОКУД открывает блок |
| `columns.py` | заголовок таблицы, колонка кодов (не меньше 60 % кодов в блоке), колонка наименований, колонки-периоды |
| `header.py` | шапка: организация, ИНН (ИНН аудитора не берётся), ОКПО, ОКЕИ, единицы «как в файле», дата периода |
| `lines.py` | строки с кодом и значения в `Decimal`; пятизначные коды — по первым четырём цифрам; дубликаты кодов |
| `checks.py` | соотношения §7 по колонкам, допуск ±1 в единицах формы, отсутствующий код = 0, `abs_terms` для знака расходов |
| `merge.py` | черновик документа и присоединение листов-продолжений к последнему открытому документу |
| `statement.py` | конвейер и итоговый `statementDocument`, критерий `ok` |
Форма и продолжение: лист с заголовком формы или ОКУД — новая форма; лист без заголовка, но с маркером «Форма … с.2»,
подписью «Код» с периодами или колонкой кодов — продолжение, присоединяется к последнему открытому документу.
Книга только из листов пояснений даёт `done` с документами `DISCLOSURE` без строк.
Критерий `ok` документа: жёсткие соотношения сошлись, дубликатов кодов нет, колонка кодов найдена, есть колонка-период,
в шапке есть организация или ИНН, документ не одинокое продолжение. Статус книги — `done`, если `ok` у всех документов,
иначе `needs_check` (список нарушений — `documents[].checks`; `soft_relations_failed` на статус не влияют).
Новый синоним или соотношение — строка в YAML (`api/rules/README.md`), плюс файл-фикстура или синтетика, на которой она
срабатывает (`test_rules_coverage.py`).
### Ограничения
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и значениях с копейками
выдаётся предупреждение; такие файлы дают `needs_check` (OQ-137).
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
одиноким продолжением и даёт `needs_check`.
- Лист ОФР с одной строкой кода (O-9, меньше `min_codes`) пропускается как не форма.
### Реальные формы: файл → статус
Источник — `api/tests/statements_manifest.yaml` (18 файлов `fixtures/statements-xlsx/**`). Статусы сверены прогоном.
| Файл | Документы и статус | Причина |
|---|---|---|
| `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится |
| `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `needs_check` | рубли с копейками при шапке «тыс. руб.»: 2100/2200/2300 расходятся с суммой частей (OQ-137) |
| `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки |
| `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив |
| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет |
| `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив |
| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки |
| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-137) |
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-137); одностроечный лист с 2400 пропущен |
| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |
| `templates/ББ после 01.01.2025.xlsx` | BALANCE `needs_check` | шаблон |
| `templates/ОФР_до 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
| `templates/ОФР_после 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
Запуск на форме: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "<путь к файлу>"` из `api/` (см. «Запуск на файле»);
в stdout — файл результата v1.2, у форм в `documents[]` — `header`, `lines[]`, `checks`.
## Тесты на реальных данных и golden-снимки
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
@ -264,6 +326,10 @@ git -C ../fixtures diff # просмотреть каждое из
git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures
```
Для форм — второй каталог: `fixtures/golden/expected_statement_parse/` (файлы `fixtures/statements-xlsx/**`, включая
`synthetic/` — синтетика `st01`–`st06`, генератор `tests/synthetic/generate_statements.py`); ожидания по реальным формам —
манифест `api/tests/statements_manifest.yaml`. Снимки обоих каталогов обновляет один `make golden-update`.
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff),
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
@ -316,7 +382,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`.
В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
В stdout печатается JSON файла результата контракта v1.2 — тот же, что воркер кладёт в S3 под
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,

View File

@ -9,7 +9,8 @@
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO),
«Реальные формы через воркер» (книга НПЗ → `needs_check`, документы BALANCE и INCOME, `result_schema_version 1.2`).
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
@ -46,6 +47,12 @@ from src.core.config import get_settings
pytestmark = pytest.mark.integration
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
REAL_NPZ = (
Path("statements-xlsx")
/ "npz"
/ "2025-Q3"
/ "Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx"
)
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
@ -313,3 +320,32 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file(
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
await core.assert_quiet(request)
@pytest.mark.fixtures
async def test_real_statements_through_worker_give_needs_check_with_two_documents(
core: Core, uploads: Uploads, fixtures_dir: Path
) -> None:
"""«Реальные формы через воркер»: книга НПЗ → ack → `needs_check` (рубли при шапке «тыс. руб.», OQ-137)."""
request = _new_request(key_name="npz_2025_q3.xlsx")
request["file_type"] = "XLSX"
await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE)
await core.publish(request)
_assert_ack(await core.next_message(request), request)
body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}")
assert body["status"] == "needs_check", body
assert body["result_schema_version"] == "1.2"
result_ref = body["result_s3"]
uploads.keys.append((result_ref["bucket"], result_ref["key"]))
stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"]))
assert schema_errors(stored) == []
assert RecognitionResult.model_validate(stored).to_wire() == stored
assert stored["status"] == "needs_check"
balance, income = stored["documents"]
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"}
assert income["checks"]["relations_failed"] != []
await core.assert_quiet(request)