diff --git a/CLAUDE.md b/CLAUDE.md index 7bd5de3..bf8717e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index 29e92ac..57a2c9c 100644 --- a/README.md +++ b/README.md @@ -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": {...}}`, diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index cbba7ac..cd3aa5f 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -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)