From dd454983e6fa2ac67e43eb8f0ae564b83fe365d1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 08:21:57 +0400 Subject: [PATCH 01/36] =?UTF-8?q?openspec:=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=203b=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=91=D0=91/=D0=9E=D0=A4?= =?UTF-8?q?=D0=A0=20=D0=B2=20=D1=8F=D0=B4=D1=80=D0=B5=20=D0=B8=20API=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=9E=D0=A1=D0=92=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, design (D-1…D-14, решения без обсуждения №1–10), дельты osv-result-mapping (маппинг форм, статус), statement-checks (новая: §7, межформенная, акцепт), documents-api (форма, правка, акцепт, подтверждение, комментарии, исходный файл), consolidations-api (CSV чек-листа), directories-api (documents_count), users-admin (справочник имён). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../slice-3b-statements-api/.openspec.yaml | 2 + .../changes/slice-3b-statements-api/design.md | 172 ++++++++++++++++++ .../slice-3b-statements-api/proposal.md | 73 ++++++++ .../specs/consolidations-api/spec.md | 35 ++++ .../specs/directories-api/spec.md | 25 +++ .../specs/documents-api/spec.md | 164 +++++++++++++++++ .../specs/osv-result-mapping/spec.md | 108 +++++++++++ .../specs/statement-checks/spec.md | 58 ++++++ .../specs/users-admin/spec.md | 17 ++ .../changes/slice-3b-statements-api/tasks.md | 6 + 10 files changed, 660 insertions(+) create mode 100644 openspec/changes/slice-3b-statements-api/.openspec.yaml create mode 100644 openspec/changes/slice-3b-statements-api/design.md create mode 100644 openspec/changes/slice-3b-statements-api/proposal.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/consolidations-api/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/directories-api/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/statement-checks/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/specs/users-admin/spec.md create mode 100644 openspec/changes/slice-3b-statements-api/tasks.md diff --git a/openspec/changes/slice-3b-statements-api/.openspec.yaml b/openspec/changes/slice-3b-statements-api/.openspec.yaml new file mode 100644 index 0000000..a26662f --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md new file mode 100644 index 0000000..75fa278 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -0,0 +1,172 @@ +# Design: Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ + +## Context + +Срез 2c построил маппинг результата распознавания в ядре (фоновый цикл, `map_file`, автозамена, `counterparty_check`, +статусы документа) только для ОСВ. Срез 4 построил привязку подтверждённых документов к версиям консолидации и чек-лист; +обе механики уже различают `BALANCE`/`INCOME` (нарастающие, `Q1` только своей версии — решения №10, R-14a Среза 4). +Модели `financial_statement_*` и `comment` есть в ORM и миграциях (DBML v5.16), но нигде не пишутся. Срезы 2b и 3a +отдают `statementDocument` v1.2 из Excel и PDF; самопроверку §7 tabular-parser кладёт в `checks`, scan-gateway — нет +(3a D-5: проверки считает ядро для обоих источников). + +## Goals / Non-Goals + +Goals: формы ББ/ОФР из обоих источников в БД; API экрана проверки с правкой, проверками §7, акцептом и подтверждением; +закрыть дыры API пути пользователя до сводной ОСВ. Non-goals — `proposal.md`. + +## Decisions + +### D-1. Маппинг форм — ветка `map_file`, а не отдельный цикл (решение владельца, подход 1) +`domain/statement_mapping.py` — чистые функции (черновики документа, шапки, колонок, строк, значений; период; статус); +`map_file` после листов ОСВ вызывает их и вставляет строки в той же транзакции. Порядок: очистка прежних данных файла +(в т.ч. `financial_statement_*` его документов) → документы ОСВ и форм → строки → статус файла → автозамена → +`on_documents_mapped`. `_load_result` и `map_file` бросают `MappingFatal` только при пустом `documents` +(«в результате нет документов»). Отдельный цикл отвергнут: два цикла делили бы файл, его статус и автозамену. + +### D-2. Документ формы, период, шапка +- `document`: `document_type` = тип формы, `osv_account_code = NULL`, `is_cumulative = NULL` (нарастающий, №10 Среза 4), + `locator` из результата, `period_check = UNKNOWN`. +- **Период**: дата = `header.report_date`, иначе самая поздняя `columns[].period_date`; `period_year` = год даты; + `period_quarter` по месяцу, если дата — последний день марта, июня, сентября или декабря; иначе квартал `NULL` и + предупреждение `: дата <дата> не конец квартала`. Без даты — год и квартал `NULL`, предупреждение. + `reporting_date` = эта дата. +- **Шапка** (`financial_statement_meta`): `statement_type` (`balance`/`income`); `inn` = `header.inn`, + `inn_original_value` = `header.inn`, `inn_confidence`, `inn_verified = (inn_confidence == 100)`; `organization_name`; + `report_date` = дата из шапки (не из колонок), `report_date_original_value = header.report_date_text`, + `report_date_verified = (report_date is not None)`; `units` = `units_original_value` = `header.units`, + `units_verified = (units is not None)`; `okud`, `okpo`, `okopf`, `okfs`, `address` — как пришли; + `*_modified = false`. Поля `*_original_value` заполняются при маппинге и больше не меняются (DBML: «как распознано»). +- **Колонки**: `column_index`, `label`, `period_date`; `is_current = true` у колонки с самой поздней `period_date` + (при равенстве — с меньшим `column_index`), у остальных `false`; без дат — у всех `NULL`. +- **Строки**: `line_code`, `line_code_original_value = line_code`, `line_code_confidence`, `line_name`, + `line_name_original_value = line_name`, `line_name_confidence`, `*_modified = false`. Повтор кода в результате (контракт + этого не запрещает) — остаётся первая строка, предупреждение (DBML: `(meta_id, line_code)` уникален). +- **Значения**: на каждую пару «строка × колонка» одна запись; из результата — `amount`, `string_value`, `confidence`, + `is_recognized`; ячейки нет в результате — `amount = NULL`, `string_value = NULL`, `confidence = NULL`, + `is_recognized = NULL`. `is_modified = false`, `original_amount = NULL` до первой правки (DBML: «значение до ручной + правки»). Значение с `column_index` вне колонок формы — пропускается с предупреждением. +- `checks` результата игнорируются: ядро считает §7 само (D-4). + +### D-3. Повтор типа формы в файле, `DISCLOSURE`/`OTHER` +Индекс `document (stored_file_id, document_type, osv_account_code)` NULLS NOT DISTINCT разрешает один документ каждого +типа формы на файл. Второй `BALANCE`/`INCOME` того же файла не создаётся: предупреждение `: вторая форма +<тип> в файле пропущена (OQ-77)`, первый документ — `NEEDS_CHECK`. `DISCLOSURE` и `OTHER` — один документ на тип на файл: +`locator` — `locator` всех таких документов результата через запятую, статус `RECOGNIZED`, данных нет (OQ-23). Документ +`BALANCE`/`INCOME` без строк (форма без колонки кодов, `st03`) создаётся с шапкой и колонками, статус `NEEDS_CHECK`. + +### D-4. Проверки §7 — перенос tabular-parser, словарь — копия +`domain/statement_checks.py`: `run_checks(form, columns, amounts, rules) -> ChecksReport` — дословный по семантике +перенос `tabular-parser/api/src/parser/statements/checks.py`: соотношение по каждой колонке, только если присутствует +его цель (левая часть); отсутствующий код = 0; допуск `tolerance` (1) в единицах формы; жёсткое нарушение не больше +`rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа соотношения — мягкое «в пределах округления»; +правая часть считается как записано и с `abs_terms` по модулю, выполнено, если хоть один вариант в допуске; мягкая +проверка «сумма пятизначных подстрок = родитель». Словарь — копия `tabular-parser/api/rules/statement_checks.yaml` в +`api/rules/statement_checks.yaml` с пометкой источника; тест идентичности между репозиториями не ставится (урок #33: +связка репозиториев ломала main), расхождение копий — известный риск, отмечается в `03-architecture.md`. +**Суммы** — текущие (после правок) `financial_statement_value.amount`. + +### D-5. Межформенная проверка — мягкая +`1370(колонка отчётной даты ББ) − 1370(колонка 31 декабря прошлого года ББ) = 2400(колонка отчётного периода ОФР)` с +тем же допуском. Пара: другой документ формы того же ЮЛ, `period_year` и `period_quarter`, файла не `SUPERSEDED` +(подтверждённый предпочтительнее, затем самый поздний `created_at`). Считается, только если найдены пара и обе колонки; +нарушение — мягкое (в `checks.cross_form`), подтверждение не блокирует: ОФР часто приходит отдельным файлом позже. + +### D-6. Статус документа формы +`RECOGNIZED`, только если одновременно: задание `done`; нет непройденных неакцептованных жёстких проверок; +`counterparty_check ≠ MISMATCH`; `period_year` и `period_quarter` определены; у каждого значения либо `is_modified`, +либо `is_recognized` не `false` и `confidence` равна 100 или `NULL`; нет предупреждений маппинга по документу; есть хотя +бы одна строка. Иначе `NEEDS_CHECK`. Статус пересчитывается при маппинге, после каждой правки и после акцепта (кроме +`CONFIRMED`). + +### D-7. Правка формы — значения и три поля шапки, только до подтверждения (решения владельца, вопросы 1 и 2) +`PATCH /documents/{id}/statement`: `header.inn` (10 или 12 цифр, иначе 422 `INVALID_INN`; пересчёт +`counterparty_check`), `header.report_date` (дата; пересчёт `period_year`, `period_quarter`, `reporting_date` по D-2), +`header.units` (непустая строка ≤ 50); `values[] {line_id, column_id, amount}` (Decimal, 2 знака; `null` допустим). +Значение: если `original_amount` пуст и значение ещё не правилось — `original_amount` = прежняя сумма; `amount` = +новая; `is_modified = (amount != original_amount)`; `modified_by/at`. Поле шапки: `*_modified = true`, исходник в +`*_original_value` не меняется. Разрешено только для `RECOGNIZED`/`NEEDS_CHECK` незаменённого файла: `CONFIRMED` — +409 `DOCUMENT_READONLY`, файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`, документ не ББ/ОФР — 409 `DOCUMENT_NOT_STATEMENT`; +`line_id`/`column_id` не этого документа — 422 `UNKNOWN_CELL`. После правки: сброс акцепта, пересчёт проверок и +статуса; одно `audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` на запрос с `before/after` изменённых полей и ячеек. +Пустой запрос (ничего не меняет) — 200 без аудита. Блокировки — порядок подтверждения: файл `FOR UPDATE` → документ +`FOR NO KEY UPDATE`. + +### D-8. Акцепт проверок +`POST /documents/{id}/statement/checks/accept {comment}` (`DOCUMENT_EDIT`; комментарий 1–2000 символов): разрешён при +непройденных жёстких проверках, иначе 409 `NOTHING_TO_ACCEPT`; те же 409, что у правки. Пишет `checks_accepted_by/at/ +comment`, пересчитывает статус, `audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` с `before/after` полей акцепта. +Отдельного `audit_action` нет; новое значение enum потребовало бы DBML v5.17 без пользы. + +### D-9. Подтверждение формы +`POST /documents/{id}/confirm` для `BALANCE`/`INCOME` дополнительно: непройденные неакцептованные жёсткие проверки — +409 `CHECKS_NOT_PASSED` (в `details` — список нарушений); `period_year` или `period_quarter` пуст — 409 +`PERIOD_REQUIRED`. Порядок проверок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → +`PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`. Пишет `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и +низкая уверенность подтверждение не блокируют (как у ОСВ). `DISCLOSURE`/`OTHER` — без условий. + +### D-10. Отдача исходного файла +`GET /files/{id}/content?disposition=attachment|inline` (`DOCUMENT_VIEW`, policy по ЮЛ): поток объекта `uploads/…` +файла из S3 частями (без чтения целиком в память); `Content-Type` по `file_type` (`application/pdf`, `image/png`, +`image/jpeg`, xls/xlsx/xlsb — свои MIME); `Content-Disposition` — ASCII-имя `file.` и `filename*` RFC 5987 с +исходным именем (как экспорт сводной ОСВ); работает и для `SUPERSEDED`; объект истёк или пропал — 404 +`FILE_CONTENT_NOT_FOUND`. Аудит не пишется (как экспорт xlsx). Прямая ссылка на S3 отвергнута: в контуре фронт может +не видеть MinIO, а через ядро действует policy. + +### D-11. Комментарии к документу +`comment(object_type = 'document', object_id, text, author_id, created_at)`. `POST /documents/{id}/comments {text}` +(`DOCUMENT_EDIT`, 1–4000 символов после обрезки пробелов) и `GET /documents/{id}/comments` (`DOCUMENT_VIEW`, по +`created_at`, без пагинации — комментариев к документу единицы) — ответ `{id, text, author: {id, full_name}, +created_at}`. Правки и удаления нет. Комментировать можно документ в любом статусе и файла `SUPERSEDED`. В карточке +документа (`DocumentBrief` и `DocumentOut`) — `comments_count`. Аудит не пишется: `comment` сам хранит автора и время. + +### D-12. Справочник имён пользователей +`GET /users/brief?ids=,…` — любой аутентифицированный пользователь; ≤ 200 id (иначе 422 +`TOO_MANY_IDS`); ответ `[{id, full_name, is_active}]` по найденным id, включая неактивных; неизвестные id не +возвращаются. Админский `GET /admin/users` не меняется. + +### D-13. Число документов у ЮЛ и CSV чек-листа +`LegalEntityOut.documents_count` — число документов файлов ЮЛ со статусом файла не `SUPERSEDED`, подзапросом на +страницу списка и в карточке. `GET …/versions/{id}/checklist?format=csv` — `text/csv; charset=utf-8` с BOM, +разделитель `;`, строка заголовка «Организация;ИНН;<правило>…» (правило — «Бух. баланс», «ОФР», «ОСВ <счёт>»), ячейки — +подписи макета: `CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` «Требуется проверка», +`ERROR` «Ошибка загрузки», `MISSING` «Отсутствует» (+ « (нет: Q1, Q2)» при `missing_quarters`); имя файла +`checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987. Аудит не пишется. + +### D-14. Редакция формы в ответе +`GET …/statement` отдаёт `edition`: `66n`, если дата периода документа (D-2) ≤ 2024-12-31, `fsbu4` — если позже, +`null` без даты. Фронту — для выбора шаблона строк; на проверки §7 в 3b не влияет (OQ-146). + +## Risks / Trade-offs + +- OCR-формы почти всегда `NEEDS_CHECK` (уверенность < 100, мусорный ИНН) — ожидаемо, аналитик правит. +- Копия словаря §7 может разойтись с tabular-parser; документировано. +- Правка даты отчёта может дать документу период, совпадающий с другим файлом ЮЛ; автозамена при правке не + запускается (только при маппинге) — в чек-листе привязывается подтверждённый позже. +- Межформенная проверка мягкая — расхождение 1370/2400 не блокирует подтверждение. + +## Migration Plan + +Миграции нет. Уже смапленные файлы с формами (до 3b формы игнорировались) форм не получают: повторный маппинг — +повторная загрузка файла пользователем (автозамена) или ручной сброс файла в `PROCESSING`; в dev-стенде — `make +seed-dev` с нуля. + +## Open Questions + +1. OQ-146: формулы §7 и справочники по редакциям `66n` / `fsbu4`. +2. OQ-152 / I-2 из 3a: повтор кода 2410 в OCR — ядро получает уже отфильтрованную строку. +3. Нужен ли аудит скачивания первички (ТЗ 8.2 «выгрузка») — сейчас нет, как у экспорта. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-07 | Период формы без даты отчёта — по самой поздней дате колонок; квартал только по концу квартала | дата отчёта в OCR часто мусор (`aep_6`), колонки надёжнее; иначе привязка к неверной версии | неверный период при перепутанных колонках — аналитик правит дату | +| 2 | 2026-10-07 | `*_original_value` шапки заполняются при маппинге, `original_amount` — при первой правке | так в DBML: «как распознано» у шапки и «до ручной правки» у значения | — | +| 3 | 2026-10-07 | Значение на каждую ячейку «строка × колонка», отсутствующие — `NULL` | у любой ячейки есть `id` для правки; фронту не нужно создавать значения | лишние строки в БД (единицы процентов) | +| 4 | 2026-10-07 | Межформенная проверка мягкая | ОФР часто приходит позже; жёсткая блокировала бы подтверждение ББ | расхождение 1370/2400 не останавливает аналитика | +| 5 | 2026-10-07 | Акцепт сбрасывается любой правкой | данные изменились — проверку нужно смотреть заново | лишний повторный акцепт | +| 6 | 2026-10-07 | Аудит акцепта — `RECOGNIZED_DATA_EDIT` | нового `audit_action` без DBML v5.17 нет | фильтр журнала по акцепту — по `object_type`/полям | +| 7 | 2026-10-07 | Контрагент и уверенность не блокируют подтверждение | паритет с ОСВ; шум OCR в ИНН | подтверждение чужой формы — защищает статус `NEEDS_CHECK` и подсветка | +| 8 | 2026-10-07 | Скачивание файла, CSV, комментарии — без аудита | паритет с экспортом xlsx; `comment` хранит автора | журнал не покажет скачивания (OQ в docs) | +| 9 | 2026-10-07 | `GET /users/brief` без отдельного права | имена нужны всем экранам; данные — только ФИО и активность | ФИО видны всем аутентифицированным | +| 10 | 2026-10-07 | Нет теста идентичности словаря §7 с tabular-parser | урок #33: межрепозиторные тесты ломают main | копии разойдутся незаметно | diff --git a/openspec/changes/slice-3b-statements-api/proposal.md b/openspec/changes/slice-3b-statements-api/proposal.md new file mode 100644 index 0000000..781109e --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/proposal.md @@ -0,0 +1,73 @@ +# Proposal: Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ + +**Срез:** 3b — вторая половина Среза 3 «ББ/ОФР: scan-gateway, маппинг форм, логические проверки, подтверждение +документа» (`../docs/context/09-delivery-plan.md`). **Репозиторий:** arm-core. **Issue:** RSHB/arm-core#36. +**Соседние:** Срез 3a (scan-gateway, `statementDocument` из OCR, закрыт 07.10.2026), Срез 2b (tabular-parser, формы из +Excel, контракт v1.2). Документация — bounded-PR в docs (§7, §9.5, OQ, `03-architecture.md`, `09-delivery-plan.md`), без +сквозного change: контракт и DBML не меняются. Объём по решению владельца 07.10.2026: **весь API пути пользователя до +сводной ОСВ включительно**; UI делает другой разработчик по этим спекам. + +## Why + +Оба сервиса распознавания отдают формы ББ/ОФР (`statementDocument` контракта v1.2), а ядро их игнорирует: документы +`BALANCE`/`INCOME` не создаются, `financial_statement_*` пусты, а файл, где есть только формы (выгрузка ББ/ОФР из 1С, +скан PDF), падает в `ERROR` с «в результате нет документов ОСВ». Поэтому столбцы «Бух. баланс» и «ОФР» чек-листа никогда +не закрываются, экрана «Проверка распознавания» нечем кормить, а консолидация не может стартовать по правилам +`required_document_rule`. Сверка пользовательского пути (`10-user-journey.md`, пути 1–2 и шаг 3.1) с API нашла и +мелкие дыры: нет отдачи исходного файла, комментариев к документу, имён пользователей для не-админов, числа документов +у ЮЛ и CSV чек-листа. + +## What Changes + +- **Маппинг форм** (`osv-result-mapping`): в той же транзакции `map_file` после листов ОСВ каждый `statementDocument` + `BALANCE`/`INCOME` становится `document` + `financial_statement_meta` + `financial_statement_column` + + `financial_statement_line` + `financial_statement_value` (значение на каждую пару «строка × колонка»); период — по дате + отчёта, без неё — по самой поздней дате колонок; `counterparty_check` — как у ОСВ; `DISCLOSURE`/`OTHER` — один документ + на тип на файл без данных. Файл без ОСВ, но с формами — не ошибка; ошибка — только пустой результат. +- **Проверки §7** (новая спека `statement-checks`): чистые функции `domain/statement_checks.py` — перенос проверок + tabular-parser (допуск ±1, округление 0,001 %, `abs_terms`, подстроки) со словарём `api/rules/statement_checks.yaml`; + межформенная `1370 − 1370(начало года) = 2400` — мягкая. Результат не хранится; акцепт непройденных жёстких проверок + с комментарием — `checks_accepted_*`. +- **API формы** (`documents-api`): `GET /documents/{id}/statement` (шапка с исходниками и признаками правки, редакция + формы, колонки, строки со значениями, проверки), `PATCH /documents/{id}/statement` (значения и ИНН / дата отчёта / + единицы; исходники сохраняются, `audit_event(RECOGNIZED_DATA_EDIT)`, пересчёт проверок и статуса, сброс акцепта), + `POST /documents/{id}/statement/checks/accept`. Подтверждённая форма — только для чтения (решение владельца). +- **Подтверждение ББ/ОФР**: дополнительно требует пройденных или акцептованных жёстких проверок и определённого периода; + пишет `financial_statement_meta.reviewed_by/at`. Привязка к версиям и переходы статуса версии — без изменений. +- **Мелкие дыры**: `GET /files/{id}/content` (поток исходного файла из S3 через ядро), комментарии + `POST/GET /documents/{id}/comments` (таблица `comment`) и `comments_count` в карточке документа, + `GET /users/brief?ids=` для любого аутентифицированного пользователя, `documents_count` в списке ЮЛ, + `GET …/checklist?format=csv`. + +## Таблицы DBML v5.16 + +Пишутся: `document` (типы `BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`), `financial_statement_meta`, +`financial_statement_column`, `financial_statement_line`, `financial_statement_value`, `comment`, `audit_event`. +Читаются: `stored_file`, `s3_object`, `recognition_job`, `legal_entity`, `legal_entity_alias`, `app_user`, +`consolidation_*`, `required_document_rule`. Новых таблиц, колонок и значений enum нет; миграции нет. + +## Параграфы `05-domain-rules.md` + +§7 (логические проверки, акцепт), §9.3 (статус документа), §9.5 (маппинг, подтверждение), §11 и §11.1 (форма из OCR), +§12 (форма из Excel; п. 5 — повтор кода, п. 7 — самопроверка), §2 и §3 п. 1 (период и привязка к версии). Правила, +которых в `05` нет и которые вводит этот change (будут дописаны в §7 и §9.5 bounded-PR docs): период ББ/ОФР по самой +поздней дате колонок при пустой дате отчёта; межформенная проверка мягкая; правка формы только до подтверждения; +условия подтверждения ББ/ОФР. + +## Допущения по открытым вопросам + +- ASSUMPTION(OQ-12, OQ-146): формулы §7 одни для редакций `66n` и `fsbu4`; варианты ФСБУ 4/2023 для 2400 — мягкие. +- ASSUMPTION(OQ-23): `DISCLOSURE` только хранится — документ без данных. +- ASSUMPTION(OQ-36, OQ-141, OQ-149): строки без кода не хранятся (их нет в результате). +- ASSUMPTION(OQ-77): вторая форма того же типа в файле не создаётся; первая — `NEEDS_CHECK` с предупреждением. +- ASSUMPTION(OQ-140): страницы формы из разных файлов не склеиваются. +- ASSUMPTION(OQ-145): редакцию формы ядро определяет само по дате отчёта; контракт не меняется. + +## Non-goals + +- UI (экраны делает другой разработчик). +- Правка строк ОСВ, добавление и удаление строк формы, правка кода и названия строки. +- Отмена подтверждения документа (исправление — новым файлом через автозамену). +- Шахматка, корректировки, КФО, ЕХ, журнал действий, «Добавить версию» (Срезы 5–7). +- Изменение контракта и DBML; хранение результатов проверок; разбор расшифровок. +- Заполнение редакции `fsbu4` и словарей по редакциям (OQ-146). diff --git a/openspec/changes/slice-3b-statements-api/specs/consolidations-api/spec.md b/openspec/changes/slice-3b-statements-api/specs/consolidations-api/spec.md new file mode 100644 index 0000000..dfaa3a7 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/consolidations-api/spec.md @@ -0,0 +1,35 @@ +# Spec Delta: consolidations-api + +## MODIFIED Requirements + +### Requirement: Чек-лист комплекта +`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила +`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`, +затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{status, +missing_quarters, document_ids}`: `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными документами (ОСВ: по +правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии); `NEEDS_CHECK` / `RECOGNIZED` — +есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии (приоритет `NEEDS_CHECK`); +`PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по `uploaded_at` файл ЮЛ в `ERROR`; +иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при частичном покрытии. Правило ОСВ +SHALL закрываться только документом с `osv_account_code`, равным `account` правила (OQ-04). Те же ячейки SHALL +отдаваться в карточке версии у каждого члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; +charset=utf-8` с BOM и разделителем `;`: заголовок «Организация;ИНН;<правило>…» (правила — «Бух. баланс», «ОФР», +«ОСВ <счёт>»), ячейки — подписи макета: `CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` +«Требуется проверка», `ERROR` «Ошибка загрузки», `MISSING` «Отсутствует» (с « (нет: Q1, Q2)» при `missing_quarters`); +`Content-Disposition` — `attachment` с именем `checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987. + +#### Scenario: Пробел Q2 +- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3 +- **THEN** ячейка ОСВ 60: `status = MISSING`, `missing_quarters = ["Q2"]`, `document_ids` — два документа + +#### Scenario: Субсчёт не закрывает правило +- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии +- **THEN** ячейка ОСВ 58 — `MISSING` + +#### Scenario: Файл в обработке +- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет +- **THEN** все ячейки ЮЛ — `PROCESSING` + +#### Scenario: Чек-лист в CSV +- **WHEN** запрошен чек-лист версии с `format=csv`, у ЮЛ подтверждён ББ, ОСВ 60 отсутствует за Q2 +- **THEN** файл начинается с BOM, первая строка — «Организация;ИНН;Бух. баланс;ОФР;ОСВ 60…», в строке ЮЛ — «Загружен» и «Отсутствует (нет: Q2)» diff --git a/openspec/changes/slice-3b-statements-api/specs/directories-api/spec.md b/openspec/changes/slice-3b-statements-api/specs/directories-api/spec.md new file mode 100644 index 0000000..272dabd --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/directories-api/spec.md @@ -0,0 +1,25 @@ +# Spec Delta: directories-api + +## MODIFIED Requirements + +### Requirement: Юридическое лицо / ИП — создание, чтение, изменение +`POST /api/v1/directories/legal-entities` (`DIRECTORY_EDIT`) SHALL создавать запись с `inn` (ЮЛ — ровно 10 цифр, ИП — 12; +иначе 422 `INVALID_INN`), `entity_type` (`ЮЛ` | `ИП`), `name`, `company_group_id` (NULL = монозаёмщик), `regional_office_id` +(по умолчанию — филиал ГК), `crm_id`; `inn` уникален (409 `INN_EXISTS`). `PATCH …/{id}` SHALL менять `name`, `entity_type`, +`crm_id`, `regional_office_id` со снимком в `legal_entity_history(event_type = UPDATE)` и `audit_event(LEGAL_ENTITY_CHANGE)`. +`GET …` SHALL давать список с фильтрами `company_group_id`, `outside_group` (`company_group_id IS NULL`), `inn` +(точно), `q` (подстрока имени/ИНН), `closed` (по `close_date`), пагинацией; `GET …/{id}` — карточку с текущими +членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. В списке и карточке SHALL быть `documents_count` — число +документов файлов ЮЛ, кроме файлов `SUPERSEDED`. + +#### Scenario: Неверная длина ИНН +- **WHEN** создаётся ИП с ИНН из 10 цифр +- **THEN** ответ 422 с кодом `INVALID_INN`, запись не создана + +#### Scenario: Монозаёмщик +- **WHEN** ЮЛ создано без `company_group_id` +- **THEN** оно отдаётся в списке с `outside_group = true` и видно пользователям филиала `regional_office_id` + +#### Scenario: Число документов +- **WHEN** у монозаёмщика файл с книгой НПЗ (ББ и ОФР) и заменённый файл с одной ОСВ +- **THEN** `documents_count = 2` diff --git a/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md new file mode 100644 index 0000000..947b2d1 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md @@ -0,0 +1,164 @@ +# Spec Delta: documents-api + +## MODIFIED Requirements + +### Requirement: Файлы организации и карточка файла +`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL +возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file`, текущее задание (`status`, `error_code`, +`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания +`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`, +`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`, +`comments_count`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404. + +#### Scenario: Список после обработки +- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR` +- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен + +#### Scenario: Счётчик комментариев +- **WHEN** к документу файла добавлены два комментария +- **THEN** в карточке файла у документа `comments_count = 2` + +### Requirement: Подтверждение документа +`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED` +или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`; +из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом +другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые +`period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; непройденные неакцептованные жёсткие проверки §7 — 409 +`CHECKS_NOT_PASSED` со списком нарушений в `details`; при подтверждении SHALL записываться +`financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT +блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, +`DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл → +документ → (advisory-блокировка привязок ГК) → версии консолидации (по возрастанию `id`)»: файл — `FOR UPDATE`, +документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки `FOR KEY SHARE`), версии — `FOR UPDATE`; состояние +перечитывается под этими блокировками; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`. +В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, +`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, +`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая +привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = +подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. + +#### Scenario: Причина ошибки маппинга в карточке файла +- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст) +- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст + +#### Scenario: Документ не подтверждается +- **WHEN** подтверждается документ в статусе `ERROR` +- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился + +#### Scenario: Подтверждение +- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала +- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK` + +#### Scenario: Привязка к версии и переход статуса +- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2 +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = пользователь + +#### Scenario: Не подходит по периоду +- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3 +- **THEN** привязки нет, статус версии не изменился + +#### Scenario: Версия в CANCELLED +- **WHEN** подходящий документ подтверждается при версии `CANCELLED` +- **THEN** привязки нет + +#### Scenario: Форма с нарушением проверок +- **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта +- **THEN** 409 `CHECKS_NOT_PASSED`, в `details` — нарушение; после акцепта подтверждение проходит + +#### Scenario: Форма без периода +- **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок +- **THEN** 409 `PERIOD_REQUIRED`; после правки даты отчёта подтверждение проходит + +#### Scenario: Форма в чек-листе +- **WHEN** версия ГК 2025 Q3 `INITIATED`, ЮЛ в периметре; подтверждается его `BALANCE` за 2025 Q3 +- **THEN** привязка создана, ячейка «Бух. баланс» чек-листа ЮЛ — `CONFIRMED`, `reviewed_by` шапки = пользователь + +## ADDED Requirements + +### Requirement: Форма ББ/ОФР для экрана проверки +`GET /api/v1/documents/{document_id}/statement` (`DOCUMENT_VIEW`, policy по ЮЛ) SHALL отдавать для документа +`BALANCE`/`INCOME`: `document` (`id`, `document_type`, `status`, `period_year`, `period_quarter`, `reporting_date`, +`counterparty_check`, `locator`, `file_id`); `edition` (`66n` при дате периода ≤ 2024-12-31, `fsbu4` — позже, `null` без +даты); `header` — для `inn`, `report_date`, `units`: `value`, `original_value`, `confidence` (у ИНН), `verified`, +`modified`; плюс `organization_name`, `okud`, `okpo`, `okopf`, `okfs`, `address`; `columns[]` (`id`, `column_index`, +`label`, `period_date`, `is_current`); `lines[]` по `line_code` (`id`, `line_code`, `line_code_confidence`, `line_name`, +`line_name_confidence`, `values[]` по `column_index`: `id`, `column_id`, `amount`, `string_value`, `confidence`, +`is_recognized`, `is_modified`, `original_amount`, `modified_by`, `modified_at`); `checks` (`failed`, `soft_failed`, +`cross_form`, `accepted`: `by`, `at`, `comment` или `null`). Документ другого типа — 409 `DOCUMENT_NOT_STATEMENT`; +чужой или несуществующий — 404. + +#### Scenario: Форма для экрана +- **WHEN** запрошена форма `BALANCE` из `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `edition = fsbu4`, 37 строк по коду, у каждой строки значения по всем колонкам, `checks.failed` пуст + +#### Scenario: Не форма +- **WHEN** запрошена форма документа `OSV` +- **THEN** 409 `DOCUMENT_NOT_STATEMENT` + +### Requirement: Правка формы +`PATCH /api/v1/documents/{document_id}/statement` (`DOCUMENT_EDIT`, policy) SHALL принимать частичное тело `header` +(`inn` — 10 или 12 цифр, иначе 422 `INVALID_INN`; `report_date` — дата; `units` — непустая строка ≤ 50) и `values[]` +(`line_id`, `column_id`, `amount` — число с ≤ 2 знаками или `null`) и в одной транзакции: при первой правке значения +записывать `original_amount` = прежняя сумма; ставить `amount`, `is_modified = (amount ≠ original_amount)`, +`modified_by/at`; для поля шапки — `*_modified = true`, `*_original_value` не менять; после `inn` — пересчитать +`counterparty_check`; после `report_date` — пересчитать `period_year`, `period_quarter`, `reporting_date`; сбросить +акцепт проверок; пересчитать статус; записать одно `audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` с `before/after` +изменённых полей и ячеек; вернуть форму, как `GET`. Документ `CONFIRMED` — 409 `DOCUMENT_READONLY`; файл `SUPERSEDED` — +409 `FILE_SUPERSEDED`; не форма — 409 `DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Запрос +без изменений — 200 без аудита. Блокировки — «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`». + +#### Scenario: Правка значения +- **WHEN** аналитик меняет сумму строки 1150 колонки 0 с 100 на 120 +- **THEN** `amount = 120`, `original_amount = 100`, `is_modified = true`, аудит `RECOGNIZED_DATA_EDIT` с `before 100`, `after 120` + +#### Scenario: Возврат к исходному +- **WHEN** затем та же ячейка меняется обратно на 100 +- **THEN** `is_modified = false`, `original_amount = 100` + +#### Scenario: Правка даты отчёта +- **WHEN** у формы OCR без периода дата отчёта правится на 2025-06-30 +- **THEN** `period_year = 2025`, `period_quarter = Q2`, `report_date_modified = true`, `report_date_original_value` не изменился + +#### Scenario: Подтверждённая форма +- **WHEN** правится форма `CONFIRMED` +- **THEN** 409 `DOCUMENT_READONLY`, данные не изменились + +### Requirement: Акцепт проверок формы +`POST /api/v1/documents/{document_id}/statement/checks/accept` (`DOCUMENT_EDIT`, policy) с `{comment}` (1–2000 символов) +SHALL выполнять акцепт (`statement-checks`), пересчитывать статус и писать `audit_event(RECOGNIZED_DATA_EDIT, +RECOGNITION)` с `before/after` полей акцепта; без непройденных жёстких проверок — 409 `NOTHING_TO_ACCEPT`; те же 409, +что у правки формы. + +#### Scenario: Акцепт +- **WHEN** у формы нарушено 1600 = 1100 + 1200, аналитик акцептует с комментарием «округление в форме клиента» +- **THEN** `checks.accepted` заполнен, документ `RECOGNIZED`, если других причин `NEEDS_CHECK` нет + +### Requirement: Комментарии к документу +`POST /api/v1/documents/{document_id}/comments` (`DOCUMENT_EDIT`, policy) с `{text}` (1–4000 символов после обрезки +пробелов) SHALL создавать `comment(object_type = 'document')` с автором и временем; `GET …/comments` (`DOCUMENT_VIEW`) +SHALL отдавать комментарии по `created_at`: `{id, text, author: {id, full_name}, created_at}`. Правки и удаления нет; +комментировать можно документ в любом статусе, в том числе файла `SUPERSEDED`. + +#### Scenario: Комментарий +- **WHEN** аналитик добавляет комментарий «нет подписи руководителя» к подтверждённому документу +- **THEN** 201, комментарий в списке с ФИО автора, `comments_count` документа = 1 + +### Requirement: Исходный файл +`GET /api/v1/files/{file_id}/content` (`DOCUMENT_VIEW`, policy) SHALL отдавать содержимое исходного объекта файла из S3 +потоком, без чтения целиком в память: `Content-Type` по `file_type`, `Content-Disposition` (`attachment` по умолчанию, +`inline` при `?disposition=inline`) с ASCII-именем и `filename*` RFC 5987 исходного имени; работает и для файла +`SUPERSEDED`. Объект не найден в S3 — 404 `FILE_CONTENT_NOT_FOUND`; чужой файл — 404. + +#### Scenario: PDF для просмотра +- **WHEN** запрошено содержимое загруженного `aep_6.pdf` с `disposition=inline` +- **THEN** 200, `Content-Type: application/pdf`, `Content-Disposition: inline; filename="file.pdf"; filename*=UTF-8''aep_6.pdf`, тело байт в байт равно загруженному + +#### Scenario: Кириллица в имени +- **WHEN** запрошен файл `ОСВ_66 за 2024.xls` +- **THEN** `filename*` содержит percent-кодированное исходное имя diff --git a/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..c8b88d2 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md @@ -0,0 +1,108 @@ +# Spec Delta: osv-result-mapping + +## MODIFIED Requirements + +### Requirement: Цикл маппинга отделён от inbox +Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done, +needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP +LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с +`results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и +в одной транзакции создать документы и строки (листы ОСВ и формы ББ/ОФР), записать `recognition_job.group_schema`, +перевести файл в `PROCESSED` и выполнить автозамену. Результат без документов SHALL давать неповторяемую ошибку +маппинга «в результате нет документов»; результат с формами, но без ОСВ, ошибкой MUST NOT быть. Inbox MUST NOT читать +файл результата и MUST NOT создавать документы. + +#### Scenario: Результат смапплен +- **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING` +- **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document` + (`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`), + `osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]` + +#### Scenario: Ключ результата вне папки задания +- **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json` +- **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется + +#### Scenario: Сбой маппинга с повтором +- **WHEN** вставка строк падает по ошибке БД +- **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR` + с `metrics.mapping_error` + +#### Scenario: Повторный маппинг идемпотентен +- **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова +- **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет + +#### Scenario: Файл только с формами +- **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` (одна форма `BALANCE`, ОСВ нет) +- **THEN** файл `PROCESSED`, создан документ `BALANCE`, ошибки «нет документов ОСВ» нет + +#### Scenario: Пустой результат +- **WHEN** результат валиден по схеме, но `documents = []` +- **THEN** файл `ERROR` с `metrics.mapping_error = "в результате нет документов"`, повтор не выполняется + +## ADDED Requirements + +### Requirement: Документ формы ББ/ОФР +Каждый `statementDocument` с `document_type ∈ {BALANCE, INCOME}` SHALL давать `document` (`osv_account_code = NULL`, +`is_cumulative = NULL`, `locator` из результата, `period_check = UNKNOWN`) и `financial_statement_meta` 1:1. Дата +периода — `header.report_date`, при её отсутствии — самая поздняя `columns[].period_date`; `period_year` — год даты, +`period_quarter` — квартал, если дата — последний день марта, июня, сентября или декабря, иначе `NULL` с +предупреждением; `reporting_date` — эта дата. Шапка: `inn`, `inn_original_value` = `header.inn`, `inn_confidence`, +`inn_verified = (inn_confidence = 100)`; `organization_name`; `report_date` = `header.report_date`, +`report_date_original_value` = `header.report_date_text`; `units` = `units_original_value` = `header.units`; `okud`, +`okpo`, `okopf`, `okfs`, `address` — как пришли; `statement_type` — `balance`/`income`. Колонки — по `columns[]`, +`is_current = true` у колонки с самой поздней `period_date`. Строки — по `lines[]` с исходниками кода и названия; повтор +кода — первая строка и предупреждение. Значение SHALL создаваться на каждую пару «строка × колонка»: из результата — +`amount`, `string_value`, `confidence`, `is_recognized`; отсутствующая ячейка — все поля `NULL`; `is_modified = false`, +`original_amount = NULL`. `checks` результата MUST NOT использоваться для статуса. `counterparty_check` — тем же +правилом, что у листа ОСВ (ИНН шапки, иначе имя или алиас владельца). + +#### Scenario: Форма из Excel +- **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** документ `BALANCE`, `period_year = 2025`, `period_quarter = Q3`, `reporting_date = 2025-09-30`; у `financial_statement_meta` 37 строк и по значению на каждую пару строка × колонка + +#### Scenario: Книга с двумя формами +- **WHEN** смаплен golden-снимок книги НПЗ `npz/2025-Q3/…НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД` +- **THEN** у файла документы `BALANCE` (132 строки) и `INCOME` (43 строки), оба за 2025 Q3 + +#### Scenario: Форма из OCR без даты отчёта +- **WHEN** смаплен эталон нормализатора `ocr/expected_normalize/aep_6` (дата отчёта `null`, колонки баланса 2025-06-30, 2024-12-31, 2023-12-31) +- **THEN** документ `BALANCE` за 2025 Q2, `is_current` — у колонки 2025-06-30, `report_date` в шапке `NULL`, `inn` `NULL` + +#### Scenario: Дата не конец квартала +- **WHEN** дата отчёта формы — 2025-10-31 +- **THEN** `period_year = 2025`, `period_quarter = NULL`, предупреждение маппинга, документ `NEEDS_CHECK` + +### Requirement: Повтор формы в файле и прочие документы +Второй документ `BALANCE` или `INCOME` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ — +`NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без +данных (`locator` — все `locator` этого типа через запятую, статус `RECOGNIZED`). Форма `BALANCE`/`INCOME` без строк +SHALL создаваться с шапкой и колонками. + +#### Scenario: Расшифровки +- **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`) +- **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую + +#### Scenario: Форма без колонки кодов +- **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0) +- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой и колонками, строк нет + +### Requirement: Статус документа формы +Документ `BALANCE`/`INCOME` SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет непройденных +неакцептованных жёстких проверок §7 (`statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и +`period_quarter` определены; у каждого значения `is_modified = true` либо (`is_recognized` не `false` и `confidence` +равна 100 или `NULL`); нет предупреждений маппинга по документу; есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Статус +SHALL пересчитываться при маппинге, после правки формы и после акцепта, кроме документа `CONFIRMED`. Документ формы +`NEEDS_CHECK` SHALL участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK +переводит версию в корректировку»). + +#### Scenario: Чистая форма Excel +- **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла) +- **THEN** документ `INCOME` `RECOGNIZED` + +#### Scenario: Форма OCR +- **WHEN** смаплен эталон `ocr/expected_normalize/aep_3` (уверенность значений < 100, ИНН шапки ≠ ИНН владельца) +- **THEN** документ `BALANCE` `NEEDS_CHECK`, `counterparty_check = MISMATCH` + +#### Scenario: Форма двигает версию +- **WHEN** версия ГК 2025 Q3 в `DOCUMENTS_PROCESSING`, в периметре ЮЛ файла; смаплена форма `BALANCE` за 2025 Q3 в `NEEDS_CHECK` +- **THEN** версия в `DATA_CORRECTION`, привязки `consolidation_document` нет diff --git a/openspec/changes/slice-3b-statements-api/specs/statement-checks/spec.md b/openspec/changes/slice-3b-statements-api/specs/statement-checks/spec.md new file mode 100644 index 0000000..edfe814 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/statement-checks/spec.md @@ -0,0 +1,58 @@ +# Spec Delta: statement-checks + +## Purpose + +Логические проверки форм ББ/ОФР §7 в ядре: чистые функции над текущими суммами документа, без хранения результата; +акцепт непройденных проверок аналитиком. + +## ADDED Requirements + +### Requirement: Соотношения §7 по колонкам +Проверки SHALL считаться чистой функцией по текущим `financial_statement_value.amount` документа для каждой колонки +отдельно по словарю `api/rules/statement_checks.yaml` (копия словаря tabular-parser): соотношение проверяется, только +если присутствует его левая часть; отсутствующий код и пустая сумма — 0; выполнено, если `|левая − правая| ≤ tolerance` +(1, в единицах формы) хотя бы в одном варианте правой части — как записано или с кодами `abs_terms` по модулю. Нарушение +жёсткого (`strict`) соотношения, не превышающее `rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа +соотношения, SHALL считаться мягким («в пределах округления»). Мягкие: соотношения `strict: false` и «сумма пятизначных +подстрок = четырёхзначный родитель». Результат — `{failed: [...], soft_failed: [...]}`, элемент — `{column_index, +relation, expected, actual}`; MUST NOT храниться в БД. + +#### Scenario: Сходящийся баланс +- **WHEN** проверяется форма `mezheninovskaya/2025-Q3/1_Форма1_бух_09` +- **THEN** `failed` пуст + +#### Scenario: Нарушение жёсткого соотношения +- **WHEN** у баланса 1600 = 1000, 1100 = 300, 1200 = 600 в колонке 0 +- **THEN** `failed` содержит `{column_index: 0, relation: "1600 = 1100 + 1200", expected: 1000, actual: 900}` + +#### Scenario: Расходы со знаком минус +- **WHEN** ОФР 2110 = 100, 2120 = −60, 2100 = 40 +- **THEN** `2100 = 2110 − 2120` выполнено через вариант с `abs_terms` + +#### Scenario: Правка исправляет нарушение +- **WHEN** аналитик правит 1200 с 600 на 700 в форме из предыдущего сценария +- **THEN** проверка по текущим суммам: `failed` пуст + +### Requirement: Межформенная проверка +Для документа `BALANCE` или `INCOME` SHALL считаться мягкая проверка `1370(колонка отчётной даты ББ) − 1370(колонка +31 декабря прошлого года ББ) = 2400(колонка отчётного периода ОФР)` с допуском `tolerance`, если найдена форма-пара: +документ другого типа формы того же ЮЛ, `period_year` и `period_quarter`, файла не `SUPERSEDED` (подтверждённый, затем +самый поздний по `created_at`), и все три колонки. Нарушение SHALL попадать в `cross_form` и MUST NOT блокировать +подтверждение. + +#### Scenario: Пара найдена +- **WHEN** у ЮЛ ББ за 2025 Q3 (1370 = 500 на 30.09.2025, 300 на 31.12.2024) и ОФР за 2025 Q3 (2400 = 150) +- **THEN** `cross_form` — нарушение `expected 200, actual 150`; подтверждение ББ разрешено + +#### Scenario: Пары нет +- **WHEN** у ЮЛ есть только ББ за период +- **THEN** `cross_form` — `null` + +### Requirement: Акцепт непройденных проверок +Акцепт SHALL записывать `financial_statement_meta.checks_accepted_by`, `checks_accepted_at`, +`checks_accepted_comment`; документ с акцептом SHALL считаться прошедшим жёсткие проверки для статуса и подтверждения. +Любая правка значений или шапки формы SHALL сбрасывать акцепт (три поля — `NULL`). + +#### Scenario: Акцепт и правка +- **WHEN** аналитик акцептовал нарушение 1600 и затем поправил любое значение +- **THEN** поля акцепта пусты, при непройденной проверке документ `NEEDS_CHECK` diff --git a/openspec/changes/slice-3b-statements-api/specs/users-admin/spec.md b/openspec/changes/slice-3b-statements-api/specs/users-admin/spec.md new file mode 100644 index 0000000..31a4f3d --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/specs/users-admin/spec.md @@ -0,0 +1,17 @@ +# Spec Delta: users-admin + +## ADDED Requirements + +### Requirement: Краткий справочник пользователей +`GET /api/v1/users/brief?ids=,…` SHALL быть доступен любому аутентифицированному пользователю без отдельного +права и отдавать `[{id, full_name, is_active}]` для найденных id, включая неактивных; неизвестные id MUST NOT +возвращаться и MUST NOT давать ошибку; больше 200 id — 422 `TOO_MANY_IDS`; некорректный UUID — 422. Админский список +пользователей не меняется. + +#### Scenario: Имя загрузившего +- **WHEN** аналитик без права `USERS_ADMIN` запрашивает `ids` из `uploaded_by` двух файлов и неизвестный UUID +- **THEN** 200, два элемента с ФИО, неизвестный UUID отсутствует + +#### Scenario: Слишком много id +- **WHEN** передан 201 id +- **THEN** 422 `TOO_MANY_IDS` diff --git a/openspec/changes/slice-3b-statements-api/tasks.md b/openspec/changes/slice-3b-statements-api/tasks.md new file mode 100644 index 0000000..f58a0c8 --- /dev/null +++ b/openspec/changes/slice-3b-statements-api/tasks.md @@ -0,0 +1,6 @@ +# Tasks: Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ + +Заполняется планом (`writing-plans`) после одобрения спеки владельцем. Репозиторий — arm-core; команды — из корня +(`make`), `pytest` — из `api/`. + +- [ ] 0.1 План реализации написан и одобрен; задачи перенесены сюда из плана -- 2.47.2 From 80b863563368083aba1a5c63e06c2c5ca08f642b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 10:51:08 +0400 Subject: [PATCH 02/36] =?UTF-8?q?plan:=20=D0=A1=D1=80=D0=B5=D0=B7=203b=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20(16=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87):=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0=20=D0=B2=20=D1=8F=D0=B4=D1=80=D0=B5,=20API?= =?UTF-8?q?=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B8,=20=D0=BC=D0=B5=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D1=8B=D1=80=D1=8B=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../2026-10-07-slice-3b-statements-api.md | 2534 +++++++++++++++++ 1 file changed, 2534 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md diff --git a/docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md b/docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md new file mode 100644 index 0000000..736b4c0 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md @@ -0,0 +1,2534 @@ +# Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** формы ББ/ОФР из tabular-parser и scan-gateway попадают в `financial_statement_*`, у фронта есть API экрана +«Проверка распознавания» (форма, правка, проверки §7, акцепт, подтверждение) и закрыты мелкие дыры API пути до сводной +ОСВ (исходный файл, комментарии, имена пользователей, `documents_count`, CSV чек-листа). + +**Architecture:** маппинг форм — ветка существующего `services/result_mapping.map_file` с чистыми функциями +`domain/statement_mapping.py`; проверки §7 — чистый `domain/statement_checks.py` (перенос tabular-parser) со словарём +`api/rules/statement_checks.yaml`; API формы — `services/statements.py` + маршруты в `api/endpoints/files/statements.py`; +мелкие дыры — точечные правки существующих сервисов и маршрутов. Контракт, DBML и миграции не меняются. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, PostgreSQL 16, pydantic v2, miniopy-async, PyYAML, pytest +(`asyncio_mode=auto`, маркеры `integration`, `fixtures`), ruff, mypy. + +**Spec:** `openspec/changes/slice-3b-statements-api/` (`proposal.md`, `design.md` D-1…D-14 и решения №1–10, +`specs/osv-result-mapping`, `specs/statement-checks`, `specs/documents-api`, `specs/consolidations-api`, +`specs/directories-api`, `specs/users-admin`). Правила: `../docs/context/05-domain-rules.md` §7, §9.3, §9.5, §11, §12. + +## Global Constraints + +- Репозиторий arm-core, ветка `feat/36-slice-3b-statements-api`, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/arm-core`; + рядом симлинки `../docs`, `../fixtures`, `../tabular-parser`, `../scan-gateway`. `make` — из корня worktree, `pytest` — + из `api/` через `.venv/bin/pytest`. Интеграционные тесты требуют `make up` (compose-проект `arm-ka`: PostgreSQL, NATS, MinIO). +- Никаких `cd` в основные checkout'ы `/Users/andrey.epifancev/projects/awp-ca/{docs,arm-core,tabular-parser,scan-gateway,fixtures}` + (они на `main`; хук guard-main блокирует записи и ломает сабагентов); читать — абсолютными путями. +- DBML v5.16 не меняется, миграций нет; новых значений enum нет. Поля — только из DBML (`financial_statement_*`, `comment`). +- Контракт `api/contracts/*` не меняется (копия v1.2 уже в ветке). +- Деньги — `Decimal`, `numeric(18,2)`; никаких `float` в суммах. Ответы API отдают суммы строкой (как `EntryOut`). +- Язык комментариев, docstring'ов, сообщений и коммитов — русский; идентификаторы — английские; строка ≤ 120 символов; + `make check` (ruff check, ruff format --check, mypy src scripts, unit-тесты) зелёный после каждой задачи. +- Права — через `require("")` на маршруте, видимость — в сервисе (чужой = 404, как у документов): чтение формы, + комментариев, файла — `DOCUMENT_VIEW`; правка, акцепт, комментарий, подтверждение — `DOCUMENT_EDIT`; CSV чек-листа — + `CONSOLIDATION_VIEW`; `/users/brief` — `current_user` без права. +- Аудит — только через `RequestAudit.record` (append-only); правка формы и акцепт — `AuditAction.RECOGNIZED_DATA_EDIT`, + `AuditSection.RECOGNITION`, `object_type = "document"`. +- Порядок блокировок правки и акцепта — как у подтверждения: `files_repo.lock_file` → `files_repo.lock_document`. +- Коммит после каждой задачи; сообщения `feat(...)`, `test(...)`, `docs(...)` на русском; в конце строк — атрибуция из + системного напоминания сессии (`Co-Authored-By: …`, `Claude-Session: …`). + +## Review Focus + +1. **Одна ячейка дважды в теле PATCH** (`values` с повтором `(line_id, column_id)`): ожидается 422 `DUPLICATE_CELL`, а не + «побеждает последняя». Тест — Task 6 (`test_patch_duplicate_cell_rejected`). +2. **Сумма с тремя знаками после запятой или больше 16 цифр до запятой** в PATCH: 422 (валидация схемы), данные не + меняются. Тест — Task 6 (`test_patch_amount_precision_rejected`). +3. **Значение результата с `column_index` вне колонок формы** (контракт не запрещает): пропускается с предупреждением + маппинга, остальное маппится. Тест — Task 2 (`test_value_outside_columns_skipped`). +4. **Прерванное скачивание большого файла**: ответ S3 закрывается и отпускается (`close` + `release`) и при обрыве + клиента. Тест — Task 9 (`test_stream_releases_on_error`). +5. **Правка даты отчёта на дату не конец квартала** (2025-10-31): квартал `NULL`, документ `NEEDS_CHECK`, подтверждение — + 409 `PERIOD_REQUIRED`. Тест — Task 7 (`test_confirm_after_non_quarter_date_edit`). + +--- + +## Структура файлов + +| Путь (от `api/`) | Ответственность | +|---|---| +| `rules/statement_checks.yaml` | копия словаря §7 tabular-parser (D-4) | +| `src/domain/statement_checks.py` | `load_check_rules`, `run_checks`, `cross_form_check` — чистые функции §7 | +| `src/domain/statement_mapping.py` | `statement_period`, `quarter_of`, `edition_for`, `build_statement`, `statement_status` | +| `src/domain/osv_mapping.py` | `counterparty_check` обобщается: `(inn, name, owner_inn, owner_names)` | +| `src/services/result_mapping.py` | ветка форм в `map_file`, `_load_result` без требования ОСВ, очистка форм файла | +| `src/repositories/statements.py` | загрузка формы документа, ячеек, пары для межформенной проверки, вставка строк | +| `src/schemas/statements.py` | `StatementOut`, `StatementPatch`, `ChecksAcceptIn`, вложенные модели | +| `src/services/statements.py` | `StatementService`: `get`, `patch`, `accept_checks`; правило статуса после правки | +| `src/api/endpoints/files/statements.py` | `GET/PATCH /documents/{id}/statement`, `POST …/checks/accept` | +| `src/services/documents.py` | условия подтверждения форм, `reviewed_by/at`, `comments_count`, комментарии | +| `src/api/endpoints/files/comments.py` | `POST/GET /documents/{id}/comments` | +| `src/core/storage.py`, `src/core/clients.py` | `ObjectStorage.stream` — поток объекта частями | +| `src/api/endpoints/files/read.py` | `GET /files/{id}/content` | +| `src/api/endpoints/users.py` | `GET /users/brief` | +| `src/services/legal_entities.py`, `src/repositories/directories.py`, `src/schemas/directories.py` | `documents_count` | +| `src/domain/checklist_csv.py`, `src/api/endpoints/consolidations/checklist.py` | CSV чек-листа | +| `tests/…` | по задаче; golden форм — `tests/test_mapping_statements.py` | + +--- + +### Task 0: Подготовка окружения + +**Files:** нет изменений. + +- [ ] **Step 1: Окружение и базовая проверка** + +```bash +cd /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/arm-core +make venv +make up +make check +cd api && .venv/bin/pytest -m integration -q -p no:cacheprovider tests/test_api_documents.py tests/test_result_mapping.py +``` +Ожидание: `make check` зелёный; интеграционные тесты документов и маппинга зелёные. Если `make up` занят другим проектом — +compose-проект общий (`arm-ka`), достаточно, чтобы контейнеры postgres/nats/minio были healthy (`docker ps`). + +- [ ] **Step 2: Коммита нет** — задача только проверяет окружение; в отчёте указать число тестов. + +--- + +### Task 1: Проверки §7 — словарь и чистые функции + +**Files:** +- Create: `api/rules/statement_checks.yaml`, `api/src/domain/statement_checks.py` +- Test: `api/tests/test_domain_statement_checks.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) RelationRule(form: Literal["BALANCE","INCOME"], relation: str, target: str, terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str])`. + - `@dataclass(frozen=True) CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, relations: tuple[RelationRule, ...])`; метод `relations_for(form) -> tuple[RelationRule, ...]`. + - `load_check_rules(path: Path | None = None) -> CheckRules` (по умолчанию `api/rules/statement_checks.yaml`), кэш `functools.lru_cache` на путь; битый файл — `ValueError` с именем файла. + - `@dataclass(frozen=True) FailedCheck(column_index: int, relation: str, expected: Decimal, actual: Decimal)`. + - `@dataclass(frozen=True) ChecksReport(failed: tuple[FailedCheck, ...], soft_failed: tuple[FailedCheck, ...])`. + - `run_checks(form: str, lines: Sequence[tuple[str, Mapping[int, Decimal | None]]], column_indexes: Sequence[int], rules: CheckRules) -> ChecksReport` — `lines` — `(line_code, {column_index: amount})` в порядке строк документа. + - `cross_form_check(balance_1370: tuple[Decimal | None, Decimal | None], income_2400: Decimal | None, tolerance: Decimal) -> FailedCheck | None` — `(1370 на отчётную дату, 1370 на начало года)`; любой `None` → `None` (не проверялось); `FailedCheck(column_index=-1, relation="1370 − 1370(начало года) = 2400", expected=Δ1370, actual=2400)`. + +- [ ] **Step 1: Скопировать словарь** + +```bash +cp /Users/andrey.epifancev/projects/awp-ca/tabular-parser/api/rules/statement_checks.yaml api/rules/statement_checks.yaml +``` +Дописать в начало файла две строки комментария (данные не трогать): +```yaml +# КОПИЯ tabular-parser/api/rules/statement_checks.yaml (Срез 3b, design D-4): меняется в обоих репозиториях одновременно. +# Межрепозиторного теста идентичности нет сознательно (решение №10); расхождение копий — известный риск (03-architecture). +``` + +- [ ] **Step 2: Падающие тесты** — `api/tests/test_domain_statement_checks.py`: + +```python +"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser.""" + +from decimal import Decimal + +import pytest + +from src.domain.statement_checks import ( + ChecksReport, + FailedCheck, + cross_form_check, + load_check_rules, + run_checks, +) + +D = Decimal +RULES = load_check_rules() + + +def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]: + return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items] + + +def test_rules_loaded_from_copy() -> None: + assert RULES.tolerance == D("1") + assert RULES.rounding_tolerance_percent == D("0.001") + relations = [rule.relation for rule in RULES.relations_for("BALANCE")] + assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations + income = {rule.relation: rule for rule in RULES.relations_for("INCOME")} + assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"}) + assert income["2400 = 2300 - 2410 + 2420"].strict is False + + +def test_hard_violation() -> None: + report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES) + assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed + + +def test_relation_skipped_without_target() -> None: + report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES) + assert report == ChecksReport(failed=(), soft_failed=()) + + +def test_tolerance_one_unit() -> None: + report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES) + assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) + + +def test_abs_terms_minus_sign() -> None: + report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES) + assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed) + + +def test_rounding_goes_soft() -> None: + # расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления» + report = run_checks( + "BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES + ) + assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) + assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed) + + +def test_soft_relation_not_hard() -> None: + report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES) + assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed) + assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed) + + +def test_sublines_soft() -> None: + report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES) + assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed) + assert report.failed == () + + +def test_per_column_and_null_as_zero() -> None: + report = run_checks( + "BALANCE", + _lines(("1100", {0: "300", 1: None}), ("1200", {0: "600", 1: "5"}), ("1600", {0: "900", 1: "5"})), + [0, 1], + RULES, + ) + assert report.failed == () + + +@pytest.mark.parametrize( + ("b1370", "i2400", "expected"), + [ + ((D("500"), D("300")), D("200"), None), + ((D("500"), D("300")), D("150"), FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150"))), + ((None, D("300")), D("200"), None), + ((D("500"), D("300")), None, None), + ], +) +def test_cross_form(b1370: tuple[Decimal | None, Decimal | None], i2400: Decimal | None, expected: FailedCheck | None) -> None: + assert cross_form_check(b1370, i2400, D("1")) == expected + + +def test_broken_rules_file(tmp_path) -> None: + bad = tmp_path / "statement_checks.yaml" + bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8") + with pytest.raises(ValueError, match="statement_checks.yaml"): + load_check_rules(bad) +``` + +- [ ] **Step 3: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_domain_statement_checks.py -q` → `ModuleNotFoundError`. + +- [ ] **Step 4: Реализация** — `api/src/domain/statement_checks.py`: + +```python +"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). + +Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в +колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по +модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше +`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = +четырёхзначный родитель» — мягкая. Результат не хранится (DBML). +""" + +import functools +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Literal + +import yaml + +__all__ = [ + "CROSS_FORM_RELATION", + "CheckRules", + "ChecksReport", + "FailedCheck", + "RelationRule", + "cross_form_check", + "load_check_rules", + "run_checks", +] + +Form = Literal["BALANCE", "INCOME"] +RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml" +CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400" +# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser — statement_headers.yaml) +CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)} +_ZERO = Decimal("0") +_HUNDRED = Decimal("100") +_PARENT_LEN = 4 +_GROUP_LEN = 3 +_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$") +_TERM = re.compile(r"([+\-])?\s*(\d{4,5})") + + +@dataclass(frozen=True) +class RelationRule: + form: Form + relation: str + target: str + terms: tuple[tuple[int, str], ...] + strict: bool + abs_terms: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class CheckRules: + tolerance: Decimal + rounding_tolerance_percent: Decimal + subline_check: bool + relations: tuple[RelationRule, ...] + + def relations_for(self, form: str) -> tuple[RelationRule, ...]: + return tuple(rule for rule in self.relations if rule.form == form) + + +@dataclass(frozen=True) +class FailedCheck: + column_index: int + relation: str + expected: Decimal + actual: Decimal + + +@dataclass(frozen=True) +class ChecksReport: + failed: tuple[FailedCheck, ...] + soft_failed: tuple[FailedCheck, ...] + + +def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule: + found = _RELATION.match(relation) + if found is None: + raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …»") + terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs"))) + unknown = set(abs_terms) - {code for _, code in terms} + if unknown: + raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»") + return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms)) + + +@functools.lru_cache(maxsize=4) +def load_check_rules(path: Path | None = None) -> CheckRules: + """Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла.""" + source = path or RULES_PATH + try: + data = yaml.safe_load(source.read_text(encoding="utf-8")) + relations = tuple( + _parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ())) + for item in data["relations"] + ) + for rule in relations: + if rule.form not in CODE_RANGES: + raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME") + return CheckRules( + tolerance=Decimal(str(data["tolerance"])), + rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))), + subline_check=bool(data.get("subline_check", True)), + relations=relations, + ) + except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc: + raise ValueError(f"словарь проверок {source.name}: {exc}") from exc + + +def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal: + total = _ZERO + for sign, code in rule.terms: + value = amounts.get(code, _ZERO) + if absolute and code in rule.abs_terms: + value = abs(value) + total += sign * value + return total + + +def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]: + expected = amounts.get(rule.target, _ZERO) + plain = _rhs(amounts, rule, absolute=False) + if abs(expected - plain) <= tolerance: + return True, expected, plain + if rule.abs_terms: + with_abs = _rhs(amounts, rule, absolute=True) + if abs(expected - with_abs) <= tolerance: + return True, expected, with_abs + if abs(expected - with_abs) < abs(expected - plain): + return False, expected, with_abs + return False, expected, plain + + +def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: + """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по порядку строк, M-9 2b).""" + low, high = CODE_RANGES[form] + parents = {code for code in codes if len(code) == _PARENT_LEN} + children: dict[str, list[str]] = {} + last_parent: str | None = None + for code in codes: + if len(code) == _PARENT_LEN: + last_parent = code + continue + if len(code) != _PARENT_LEN + 1: + continue + if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]: + parent = last_parent + elif code[:_PARENT_LEN] in parents: + parent = code[:_PARENT_LEN] + else: + continue + children.setdefault(parent, []).append(code) + rules: list[RelationRule] = [] + for parent, kids in children.items(): + if parent.isdigit() and low <= int(parent) <= high: + relation = f"{parent} = {' + '.join(kids)}" + rules.append(RelationRule(form, relation, parent, tuple((1, kid) for kid in kids), False)) # type: ignore[arg-type] + return rules + + +def run_checks( + form: str, + lines: Sequence[tuple[str, Mapping[int, Decimal | None]]], + column_indexes: Sequence[int], + rules: CheckRules, +) -> ChecksReport: + """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` по порядку строк.""" + explicit = list(rules.relations_for(form)) + sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else [] + hard: list[FailedCheck] = [] + soft: list[FailedCheck] = [] + for column_index in column_indexes: + amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} + for rule in [*explicit, *sublines]: + if rule not in sublines and rule.target not in amounts: + continue + holds, expected, actual = _holds(amounts, rule, rules.tolerance) + if holds: + continue + failed = FailedCheck(column_index, rule.relation, expected, actual) + scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)]) + rounding = scale * rules.rounding_tolerance_percent / _HUNDRED + if rule.strict and abs(expected - actual) > rounding: + hard.append(failed) + else: + soft.append(failed) + return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft)) + + +def cross_form_check( + balance_1370: tuple[Decimal | None, Decimal | None], income_2400: Decimal | None, tolerance: Decimal +) -> FailedCheck | None: + """Межформенная §7 (мягкая, D-5): `1370(отчётная дата) − 1370(начало года) = 2400(период)`; неполные данные — None.""" + current, year_start = balance_1370 + if current is None or year_start is None or income_2400 is None: + return None + delta = current - year_start + if abs(delta - income_2400) <= tolerance: + return None + return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400) +``` + +Примечание: `RelationRule` у подстрок строится с `form` типа `str`; если mypy ругается — привести `form` к `Form` через +`cast(Form, form)` вместо `# type: ignore`. + +- [ ] **Step 5: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_domain_statement_checks.py -q && cd .. && make lint` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add api/rules/statement_checks.yaml api/src/domain/statement_checks.py api/tests/test_domain_statement_checks.py +git commit -m "feat(statements): проверки §7 в ядре — перенос tabular-parser, словарь statement_checks.yaml (D-4, D-5)" +``` + +--- + +### Task 2: Чистые функции маппинга формы + +**Files:** +- Create: `api/src/domain/statement_mapping.py` +- Modify: `api/src/domain/osv_mapping.py` (обобщить `counterparty_check`) +- Test: `api/tests/test_domain_statement_mapping.py`, `api/tests/test_domain_osv_mapping.py` (адаптация вызовов) + +**Interfaces:** +- Consumes: `src.contract.result.StatementDocument` (`header`, `columns`, `lines`), `ChecksReport`, `run_checks`, `load_check_rules` (Task 1). +- Produces (в `src/domain/statement_mapping.py`): + - `quarter_of(day: date) -> PeriodQuarter | None` — `Q1..Q4` только для 31.03, 30.06, 30.09, 31.12. + - `statement_period(report_date: date | None, column_dates: Sequence[date | None]) -> date | None` — дата отчёта, иначе максимальная дата колонок, иначе `None`. + - `edition_for(period_date: date | None) -> str | None` — `"66n"` при ≤ 2024-12-31, `"fsbu4"` позже, `None` без даты. + - `@dataclass ValueDraft(line_code: str, column_index: int, amount: Decimal | None, string_value: str | None, confidence: Decimal | None, is_recognized: bool | None)`. + - `@dataclass LineDraft(line_code: str, line_code_confidence: Decimal | None, line_name: str, line_name_confidence: Decimal | None)`. + - `@dataclass StatementDraft(document_type: DocumentType, statement_type: StatementType, locator: str, period_date: date | None, period_year: int | None, period_quarter: PeriodQuarter | None, header: dict[str, Any], columns: list[tuple[int, str, date | None, bool | None]], lines: list[LineDraft], values: list[ValueDraft], warnings: list[str])` — `header` — поля `financial_statement_meta` (ключи — имена колонок ORM), `columns` — `(column_index, label, period_date, is_current)`. + - `build_statement(doc: StatementDocument) -> StatementDraft` — D-2 (значение на каждую пару строка × колонка, повтор кода, значение вне колонок, период, `is_current`). + - `statement_status(*, job_done: bool, checks_passed: bool, counterparty: DocumentCheckResult, period_known: bool, values_ok: bool, has_lines: bool, warnings: Sequence[str]) -> DocumentStatus` — D-6. + - `values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool` — по кортежам `(is_modified, is_recognized, confidence)`. +- Produces (в `src/domain/osv_mapping.py`): `counterparty_check(inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str]) -> DocumentCheckResult`; вызовы для ОСВ передают `header.organization_inn`, `header.organization_name`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_domain_statement_mapping.py`: + +```python +"""Чистые функции маппинга формы (spec osv-result-mapping «Документ формы ББ/ОФР»; design D-2, D-3, D-6, D-14).""" + +import datetime +from decimal import Decimal + +import pytest + +from src.contract.result import StatementDocument +from src.domain.statement_mapping import ( + build_statement, + edition_for, + quarter_of, + statement_period, + statement_status, + values_ok, +) +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType + +d = datetime.date + + +@pytest.mark.parametrize( + ("day", "quarter"), + [(d(2025, 3, 31), PeriodQuarter.Q1), (d(2025, 6, 30), PeriodQuarter.Q2), (d(2025, 9, 30), PeriodQuarter.Q3), + (d(2024, 12, 31), PeriodQuarter.Q4), (d(2025, 10, 31), None), (d(2025, 6, 29), None)], +) +def test_quarter_of(day: datetime.date, quarter: PeriodQuarter | None) -> None: + assert quarter_of(day) is quarter + + +def test_period_and_edition() -> None: + assert statement_period(d(2025, 9, 30), [d(2024, 12, 31)]) == d(2025, 9, 30) + assert statement_period(None, [d(2024, 12, 31), d(2025, 6, 30), None]) == d(2025, 6, 30) + assert statement_period(None, [None]) is None + assert edition_for(d(2024, 12, 31)) == "66n" and edition_for(d(2025, 3, 31)) == "fsbu4" and edition_for(None) is None + + +def _doc(**overrides: object) -> StatementDocument: + data: dict[str, object] = { + "document_type": "BALANCE", + "locator": "sheet:ББ", + "header": {"form_name": "Бухгалтерский баланс", "inn": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "units": "тыс. руб.", + "organization_name": "ООО Пример", "okud": "0710001"}, + "columns": [{"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"}, + {"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}], + "lines": [ + {"line_code": "1150", "line_code_confidence": 100, "line_name": "ОС", "line_name_confidence": 100, + "values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}]}, + {"line_code": "1150", "line_name": "дубль", "values": []}, + {"line_code": "1600", "line_name": "БАЛАНС", + "values": [{"column_index": 5, "amount": "1", "confidence": 100, "is_recognized": True}]}, + ], + } + data.update(overrides) + return StatementDocument.model_validate(data) + + +def test_build_statement_cells_and_header() -> None: + draft = build_statement(_doc()) + assert draft.document_type is DocumentType.BALANCE and draft.statement_type is StatementType.balance + assert (draft.period_year, draft.period_quarter, draft.period_date) == (2025, PeriodQuarter.Q3, d(2025, 9, 30)) + assert draft.header["inn"] == "7700000000" and draft.header["inn_original_value"] == "7700000000" + assert draft.header["inn_verified"] is True + assert draft.header["report_date_original_value"] == "на 30 сентября 2025 г." + assert draft.header["units"] == draft.header["units_original_value"] == "тыс. руб." + assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert [line.line_code for line in draft.lines] == ["1150", "1600"] # дубль 1150 отброшен + cells = {(v.line_code, v.column_index): v for v in draft.values} + assert len(cells) == 4 # 2 строки × 2 колонки + assert cells[("1150", 0)].amount == Decimal("10") and cells[("1150", 1)].amount is None + assert cells[("1150", 1)].is_recognized is None and cells[("1600", 0)].confidence is None + assert any("1150" in w and "повтор" in w for w in draft.warnings) + assert any("колонк" in w and "5" in w for w in draft.warnings) + + +def test_value_outside_columns_skipped() -> None: + """Review Focus 3: значение с column_index вне колонок — пропущено с предупреждением, остальное смаплено.""" + draft = build_statement(_doc()) + assert all(v.column_index in (0, 1) for v in draft.values) + + +def test_period_from_columns_and_non_quarter() -> None: + no_date = _doc(header={"form_name": "ББ", "report_date": None}) + draft = build_statement(no_date) + assert (draft.period_year, draft.period_quarter) == (2025, PeriodQuarter.Q3) + assert draft.header["report_date"] is None + october = _doc(header={"form_name": "ББ", "report_date": "2025-10-31"}) + draft = build_statement(october) + assert (draft.period_year, draft.period_quarter) == (2025, None) + assert any("конец квартала" in w for w in draft.warnings) + + +def test_no_dates_at_all() -> None: + draft = build_statement(_doc(header={"form_name": "ББ"}, columns=[{"column_index": 0, "label": ""}])) + assert (draft.period_year, draft.period_quarter, draft.period_date) == (None, None, None) + assert draft.columns == [(0, "", None, None)] + + +def test_status_rules() -> None: + ok = dict(job_done=True, checks_passed=True, counterparty=DocumentCheckResult.MATCH, period_known=True, + values_ok=True, has_lines=True, warnings=[]) + assert statement_status(**ok) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + for key, bad in [("job_done", False), ("checks_passed", False), ("counterparty", DocumentCheckResult.MISMATCH), + ("period_known", False), ("values_ok", False), ("has_lines", False), ("warnings", ["x"])]: + assert statement_status(**{**ok, key: bad}) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type] + assert statement_status(**{**ok, "counterparty": DocumentCheckResult.UNKNOWN}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + + +def test_values_ok() -> None: + hundred = Decimal("100") + assert values_ok([(False, True, hundred), (False, None, None)]) + assert not values_ok([(False, False, hundred)]) + assert not values_ok([(False, True, Decimal("99"))]) + assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз +``` + +В `api/tests/test_domain_osv_mapping.py` найти вызовы `counterparty_check(header, …)` (`grep -n counterparty_check`) и +переписать на `counterparty_check(header.organization_inn, header.organization_name, …)`; добавить тест: + +```python +def test_counterparty_check_plain_values() -> None: + names = frozenset({normalize_alias("ООО Ромашка")}) + assert counterparty_check("7700000000", None, "7700000000", names) is DocumentCheckResult.MATCH + assert counterparty_check(None, "ООО «Ромашка»", "7700000001", names) is DocumentCheckResult.MATCH + assert counterparty_check("3121200351", "мусор", "7700000000", names) is DocumentCheckResult.MISMATCH + assert counterparty_check(None, None, "7700000000", names) is DocumentCheckResult.UNKNOWN +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_domain_statement_mapping.py tests/test_domain_osv_mapping.py -q`. + +- [ ] **Step 3: `counterparty_check`** в `src/domain/osv_mapping.py`: + +```python +def counterparty_check( + inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str] +) -> DocumentCheckResult: + """D-8 Среза 2c, общий для ОСВ и форм (Срез 3b): организация в шапке — владелец файла? + + ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет → + `normalize_alias(name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`. + Нет ни ИНН, ни имени → `UNKNOWN`. + """ + header_inn = _NON_DIGITS_RE.sub("", inn or "") + if header_inn: + return ( + DocumentCheckResult.MATCH + if header_inn == _NON_DIGITS_RE.sub("", owner_inn) + else DocumentCheckResult.MISMATCH + ) + normalized = normalize_alias(name) + if not normalized: + return DocumentCheckResult.UNKNOWN + return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH +``` +В `src/services/result_mapping.py` вызов заменить на +`counterparty_check(sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names)`; убрать +неиспользуемый импорт `OsvHeader` из `osv_mapping.py`, если он больше не нужен. + +- [ ] **Step 4: `statement_mapping.py`**: + +```python +"""Чистые функции маппинга формы ББ/ОФР (`statementDocument` v1.2) в черновики `financial_statement_*` +(spec osv-result-mapping «Документ формы ББ/ОФР», «Статус документа формы»; design D-2, D-3, D-6, D-14).""" + +import datetime +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from src.contract.result import StatementDocument +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType + +__all__ = [ + "LineDraft", + "StatementDraft", + "ValueDraft", + "build_statement", + "edition_for", + "quarter_of", + "statement_period", + "statement_status", + "values_ok", +] + +FULL_CONFIDENCE = Decimal("100") +EDITION_BOUNDARY = datetime.date(2024, 12, 31) +_QUARTER_ENDS = {(3, 31): PeriodQuarter.Q1, (6, 30): PeriodQuarter.Q2, (9, 30): PeriodQuarter.Q3, (12, 31): PeriodQuarter.Q4} +_STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income} + + +@dataclass +class ValueDraft: + line_code: str + column_index: int + amount: Decimal | None + string_value: str | None + confidence: Decimal | None + is_recognized: bool | None + + +@dataclass +class LineDraft: + line_code: str + line_code_confidence: Decimal | None + line_name: str + line_name_confidence: Decimal | None + + +@dataclass +class StatementDraft: + document_type: DocumentType + statement_type: StatementType + locator: str + period_date: datetime.date | None + period_year: int | None + period_quarter: PeriodQuarter | None + header: dict[str, Any] + columns: list[tuple[int, str, datetime.date | None, bool | None]] + lines: list[LineDraft] + values: list[ValueDraft] + warnings: list[str] = field(default_factory=list) + + +def quarter_of(day: datetime.date) -> PeriodQuarter | None: + return _QUARTER_ENDS.get((day.month, day.day)) + + +def statement_period(report_date: datetime.date | None, column_dates: Sequence[datetime.date | None]) -> datetime.date | None: + """D-2: дата отчёта, иначе самая поздняя дата колонок.""" + if report_date is not None: + return report_date + dates = [day for day in column_dates if day is not None] + return max(dates) if dates else None + + +def edition_for(period_date: datetime.date | None) -> str | None: + """D-14: редакция формы для шаблона фронта.""" + if period_date is None: + return None + return "66n" if period_date <= EDITION_BOUNDARY else "fsbu4" + + +def _decimal(value: float | None) -> Decimal | None: + return Decimal(str(value)) if value is not None else None + + +def build_statement(doc: StatementDocument) -> StatementDraft: + """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение на каждую ячейку.""" + document_type = DocumentType(doc.document_type) + header = doc.header + warnings: list[str] = [] + column_dates = [column.period_date for column in doc.columns] + period_date = statement_period(header.report_date, column_dates) + period_year = period_date.year if period_date is not None else None + period_quarter = quarter_of(period_date) if period_date is not None else None + if period_date is None: + warnings.append(f"{doc.locator}: период формы не определён (нет даты отчёта и дат колонок)") + elif period_quarter is None: + warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён") + latest = max((day for day in column_dates if day is not None), default=None) + columns = [ + ( + column.column_index, + column.label, + column.period_date, + None if latest is None else column.period_date == latest + and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest), + ) + for column in doc.columns + ] + known_columns = {column.column_index for column in doc.columns} + lines: list[LineDraft] = [] + values: list[ValueDraft] = [] + seen: set[str] = set() + for line in doc.lines: + if line.line_code in seen: + warnings.append(f"{doc.locator}: код {line.line_code} повторяется — оставлена первая строка") + continue + seen.add(line.line_code) + lines.append( + LineDraft(line.line_code, _decimal(line.line_code_confidence), line.line_name, _decimal(line.line_name_confidence)) + ) + by_column = {} + for value in line.values: + if value.column_index not in known_columns: + warnings.append( + f"{doc.locator}: строка {line.line_code}: значение колонки {value.column_index} вне колонок формы пропущено" + ) + continue + by_column.setdefault(value.column_index, value) + for column in doc.columns: + value = by_column.get(column.column_index) + values.append( + ValueDraft( + line.line_code, + column.column_index, + value.amount if value is not None else None, + value.string_value if value is not None else None, + _decimal(value.confidence) if value is not None else None, + value.is_recognized if value is not None else None, + ) + ) + inn_confidence = _decimal(header.inn_confidence) + meta = { + "inn": header.inn, + "inn_original_value": header.inn, + "inn_confidence": inn_confidence, + "inn_verified": inn_confidence == FULL_CONFIDENCE if header.inn is not None else None, + "inn_modified": False, + "organization_name": header.organization_name, + "report_date": header.report_date, + "report_date_original_value": header.report_date_text, + "report_date_verified": header.report_date is not None, + "report_date_modified": False, + "units": header.units, + "units_original_value": header.units, + "units_verified": header.units is not None, + "units_modified": False, + "okud": header.okud, + "okpo": header.okpo, + "okopf": header.okopf, + "okfs": header.okfs, + "address": header.address, + } + return StatementDraft( + document_type=document_type, + statement_type=_STATEMENT_TYPES[document_type], + locator=doc.locator, + period_date=period_date, + period_year=period_year, + period_quarter=period_quarter, + header=meta, + columns=columns, + lines=lines, + values=values, + warnings=warnings, + ) + + +def values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool: + """D-6: каждое значение правлено аналитиком или распознано без сомнений (`is_recognized` не false, уверенность 100/NULL).""" + return all( + modified or (recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE)) + for modified, recognized, confidence in values + ) + + +def statement_status( # noqa: PLR0913 - условия D-6 перечислены явно + *, + job_done: bool, + checks_passed: bool, + counterparty: DocumentCheckResult, + period_known: bool, + values_ok: bool, + has_lines: bool, + warnings: Sequence[str], +) -> DocumentStatus: + """D-6: `RECOGNIZED` только при всех условиях; иначе `NEEDS_CHECK`.""" + recognized = ( + job_done and checks_passed and counterparty != DocumentCheckResult.MISMATCH and period_known + and values_ok and has_lines and not warnings + ) + return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK +``` + +Упростить вычисление `is_current` при ревью можно, но семантика фиксирована: `True` у колонки с самой поздней датой (при +равенстве — с меньшим `column_index`), `False` у остальных при наличии дат, `None` у всех без дат. Тип `confidence` в +контракте (`Confidence = float | None`) — поэтому `_decimal` через `str`. + +- [ ] **Step 5: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_domain_statement_mapping.py tests/test_domain_osv_mapping.py -q && cd .. && make lint` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add api/src/domain/statement_mapping.py api/src/domain/osv_mapping.py api/src/services/result_mapping.py api/tests/test_domain_statement_mapping.py api/tests/test_domain_osv_mapping.py +git commit -m "feat(statements): чистые функции маппинга формы — период, черновик, статус; counterparty_check общий (D-2, D-6)" +``` + +--- + +### Task 3: Помощники golden-тестов форм + +**Files:** +- Modify: `api/tests/fixtures_helpers.py` +- Test: `api/tests/test_fixtures_marker.py` (дописать проверки помощников) + +**Interfaces:** +- Produces: + - `ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult` — эталон нормализатора + `fixtures/ocr/expected_normalize/.json` (`{status, warnings, metrics, error, documents}`) как `RecognitionResult`: + `schema_version = "1.2"`, `service = {"name": "scan-gateway", "version": "golden"}`, `status`, `documents`, + `error` и `metrics` как есть (если не `None`). + - `map_golden_snapshot(..., result_builder=golden_result)` — новый необязательный именованный параметр + `result_builder: Callable[..., RecognitionResult]`; возвращает **все** документы файла, отсортированные по + `(document_type, osv_account_code)` (было — только по `osv_account_code`). + - Константы: `GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse"`, + `OCR_EXPECTED = Path("ocr") / "expected_normalize"`. + +- [ ] **Step 1: Падающий тест** — в `api/tests/test_fixtures_marker.py` дописать: + +```python +@pytest.mark.fixtures +def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None: + import uuid + + from tests.fixtures_helpers import OCR_EXPECTED, ocr_result + + job_id, file_id = uuid.uuid4(), uuid.uuid4() + result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_6.json", job_id=job_id, file_id=file_id) + assert result.job_id == job_id and result.file_id == file_id + assert result.service.name == "scan-gateway" + assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"] +``` + +- [ ] **Step 2: Убедиться, что падает** — `cd api && .venv/bin/pytest tests/test_fixtures_marker.py -q -k ocr_result`. + +- [ ] **Step 3: Реализация** в `api/tests/fixtures_helpers.py`: + +```python +GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse" +OCR_EXPECTED = Path("ocr") / "expected_normalize" + + +def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": + """Эталон нормализатора scan-gateway (`ocr/expected_normalize/.json`) как `RecognitionResult` контракта v1.2.""" + from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest + + data = json.loads(path.read_text("utf-8")) + payload: dict[str, object] = { + "schema_version": "1.2", + "job_id": str(job_id), + "file_id": str(file_id), + "service": {"name": "scan-gateway", "version": "golden"}, + "status": data["status"], + "documents": data["documents"], + } + if data.get("error"): + payload["error"] = data["error"] + if data.get("metrics"): + payload["metrics"] = data["metrics"] + return RecognitionResult.model_validate(payload) +``` + +В `map_golden_snapshot` добавить параметр `result_builder: "Callable[..., RecognitionResult] | None" = None` (по +умолчанию `golden_result`), использовать `(result_builder or golden_result)(path, job_id=job.id, file_id=file.id)`; +сортировку документов заменить на `.order_by(Document.document_type, Document.osv_account_code)`. Существующие вызовы +не меняются (ОСВ-документы — те же; сортировка по типу для ОСВ ничего не меняет). + +- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_fixtures_marker.py -q && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/tests/fixtures_helpers.py api/tests/test_fixtures_marker.py +git commit -m "test(statements): помощники golden — ocr_result, все документы файла в map_golden_snapshot" +``` + +--- + +### Task 4: Маппинг форм в `map_file` + +**Files:** +- Create: `api/src/repositories/statements.py` +- Modify: `api/src/services/result_mapping.py` (`_load_result`, `_clear_file`, `map_file`) +- Test: `api/tests/test_mapping_statements.py` (новый), `api/tests/test_result_mapping.py` (сценарий «нет документов ОСВ» → «нет документов») + +**Interfaces:** +- Consumes: `build_statement`, `statement_status`, `values_ok` (Task 2); `run_checks`, `load_check_rules` (Task 1); + `counterparty_check` (Task 2); `ocr_result`, `map_golden_snapshot`, `GOLDEN_STATEMENTS`, `OCR_EXPECTED` (Task 3). +- Produces: + - `repositories/statements.py`: + - `async def insert_statement(session, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft) -> FinancialStatementMeta` — вставляет meta, колонки, строки и значения (пакетами по 1000), возвращает meta. + - `async def delete_file_statements(session, file_id: uuid.UUID) -> None` — удаляет `financial_statement_value/line/column/meta` документов файла (в порядке FK). + - `async def statement_amounts(session, meta_id: uuid.UUID) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]` — индексы колонок и `(line_code, {column_index: amount})` по порядку строк (по `line_code`) — вход `run_checks`. + - `async def value_flags(session, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]` — `(is_modified, is_recognized, confidence)` всех значений — вход `values_ok`. + - `services/result_mapping.py`: константа `NO_DOCUMENTS = "в результате нет документов"`; `MappingFatal(NO_DOCUMENTS)` вместо прежнего текста; формы маппятся после листов ОСВ. + - Статус формы при маппинге: `checks_passed = not run_checks(...).failed` (акцепта при маппинге нет). + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_mapping_statements.py`: + +```python +"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и +прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures.""" + +import uuid +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document, StoredFile +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from tests.factories import make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" +NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3" +SYNTH = GOLDEN_STATEMENTS / "synthetic" + + +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _count(session: AsyncSession, model: type, meta_id: uuid.UUID) -> int: + if model is FinancialStatementValue: + stmt = ( + select(func.count()) + .select_from(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + else: + stmt = select(func.count()).select_from(model).where(model.meta_id == meta_id) + return (await session.execute(stmt)).scalar_one() + + +async def _owner(session: AsyncSession, path: Path) -> object: + """Владелец файла с ИНН шапки формы (MATCH), чтобы чистые формы могли быть RECOGNIZED.""" + import json + + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name="ООО Владелец", inn=inn) + + +async def test_file_with_forms_only(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма1_бух_09.json" + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert [d.document_type for d in documents] == [DocumentType.BALANCE] + balance = documents[0] + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q3) + assert balance.reporting_date.isoformat() == "2025-09-30" + assert balance.osv_account_code is None and balance.is_cumulative is None + meta = await _meta(seeded, balance) + lines = await _count(seeded, FinancialStatementLine, meta.id) + columns = await _count(seeded, FinancialStatementColumn, meta.id) + assert lines == 37 + assert await _count(seeded, FinancialStatementValue, meta.id) == lines * columns + file = await seeded.get(StoredFile, balance.stored_file_id) + assert file.status is FileStatus.PROCESSED + + +async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, path) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + assert income.counterparty_check is DocumentCheckResult.MATCH + assert income.status is DocumentStatus.RECOGNIZED + + +async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = next((fixtures_dir / NPZ).glob("*.json")) + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + by_type = {d.document_type: d for d in documents} + assert set(by_type) == {DocumentType.BALANCE, DocumentType.INCOME} + assert await _count(seeded, FinancialStatementLine, (await _meta(seeded, by_type[DocumentType.BALANCE])).id) == 132 + assert await _count(seeded, FinancialStatementLine, (await _meta(seeded, by_type[DocumentType.INCOME])).id) == 43 + + +async def test_disclosures_merged(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Синтетика") + documents = await map_golden_snapshot(seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None) + types = [d.document_type for d in documents] + assert types.count(DocumentType.DISCLOSURE) == 1 and DocumentType.INCOME in types + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED and disclosure.locator.count(",") == 4 + + +async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Без кодов") + (balance,) = await map_golden_snapshot(seeded, entity=owner, path=fixtures_dir / SYNTH / "st03_no_codes.json", uploaded_by=None) + assert balance.status is DocumentStatus.NEEDS_CHECK + meta = await _meta(seeded, balance) + assert await _count(seeded, FinancialStatementLine, meta.id) == 0 + assert await _count(seeded, FinancialStatementColumn, meta.id) >= 1 + + +async def test_mixed_book_osv_and_balance(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Смешанная") + documents = await map_golden_snapshot(seeded, entity=owner, path=fixtures_dir / SYNTH / "st04_mixed_book.json", uploaded_by=None) + assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE} + + +async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж") + documents = await map_golden_snapshot( + seeded, entity=owner, path=fixtures_dir / OCR_EXPECTED / "aep_6.json", uploaded_by=None, result_builder=ocr_result + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q2) + meta = await _meta(seeded, balance) + assert meta.report_date is None and meta.inn is None + current = ( + await seeded.execute( + select(FinancialStatementColumn.period_date).where( + FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.is_current.is_(True) + ) + ) + ).scalar_one() + assert current.isoformat() == "2025-06-30" + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_ocr_noisy_form_mismatch(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Другое", inn="7700000000") + documents = await map_golden_snapshot( + seeded, entity=owner, path=fixtures_dir / OCR_EXPECTED / "aep_3.json", uploaded_by=None, result_builder=ocr_result + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert balance.counterparty_check is DocumentCheckResult.MISMATCH and balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_all_statement_snapshots_map(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Все golden форм (Excel и OCR, кроме ответов-ошибок) маппятся без исключений; у каждой формы значения на все ячейки.""" + owner = await make_legal_entity(seeded, name="ООО Все формы") + excel = sorted((fixtures_dir / GOLDEN_STATEMENTS).rglob("*.json")) + ocr = sorted(p for p in (fixtures_dir / OCR_EXPECTED).glob("*.json")) + assert len(excel) >= 25 + mapped = 0 + for path in excel: + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + mapped += 1 + import json + + for path in ocr: + if json.loads(path.read_text("utf-8"))["status"] == "error": + continue + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None, result_builder=ocr_result) + mapped += 1 + assert mapped >= 27 + metas = (await seeded.execute(select(FinancialStatementMeta.id))).scalars().all() + for meta_id in metas: + lines = await _count(seeded, FinancialStatementLine, meta_id) + columns = await _count(seeded, FinancialStatementColumn, meta_id) + assert await _count(seeded, FinancialStatementValue, meta_id) == lines * columns +``` + +В `api/tests/test_result_mapping.py` найти сценарий с текстом «нет документов ОСВ» (`grep -n "нет документов ОСВ"`): для +результата **без документов** ожидается `"в результате нет документов"`; результат только с `BALANCE` больше не ошибка — +если в файле есть тест «результат без ОСВ → ERROR» с формой, переписать его на `documents = []`. + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_mapping_statements.py -q -p no:cacheprovider` (нужен `make up`). + +- [ ] **Step 3: Репозиторий** — `api/src/repositories/statements.py`: + +```python +"""Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений. +Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`.""" + +import uuid +from collections.abc import Sequence +from decimal import Decimal + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_mapping import StatementDraft +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + +BATCH = 1000 + + +async def insert_statement( + session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft +) -> FinancialStatementMeta: + meta = FinancialStatementMeta( + id=uuid.uuid4(), + document_id=document_id, + recognition_job_id=job_id, + statement_type=draft.statement_type, + **draft.header, + ) + session.add(meta) + await session.flush() + column_ids: dict[int, uuid.UUID] = {} + columns = [] + for column_index, label, period_date, is_current in draft.columns: + column_ids[column_index] = uuid.uuid4() + columns.append( + {"id": column_ids[column_index], "meta_id": meta.id, "column_index": column_index, "label": label, + "period_date": period_date, "is_current": is_current} + ) + if columns: + await session.execute(insert(FinancialStatementColumn).values(columns)) + line_ids: dict[str, uuid.UUID] = {} + lines = [] + for line in draft.lines: + line_ids[line.line_code] = uuid.uuid4() + lines.append( + {"id": line_ids[line.line_code], "meta_id": meta.id, "line_code": line.line_code, + "line_code_original_value": line.line_code, "line_code_confidence": line.line_code_confidence, + "line_code_modified": False, "line_name": line.line_name, "line_name_original_value": line.line_name, + "line_name_confidence": line.line_name_confidence, "line_name_modified": False} + ) + for start in range(0, len(lines), BATCH): + await session.execute(insert(FinancialStatementLine).values(lines[start : start + BATCH])) + values = [ + {"id": uuid.uuid4(), "line_id": line_ids[value.line_code], "column_id": column_ids[value.column_index], + "amount": value.amount, "string_value": value.string_value, "confidence": value.confidence, + "is_recognized": value.is_recognized, "is_modified": False} + for value in draft.values + ] + for start in range(0, len(values), BATCH): + await session.execute(insert(FinancialStatementValue).values(values[start : start + BATCH])) + return meta + + +async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: + documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() + lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() + await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(lines))) + await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementColumn).where(FinancialStatementColumn.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) + + +async def statement_amounts( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: + columns = ( + await session.execute( + select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).all() + index_by_id = {column_id: index for column_id, index in columns} + rows = ( + await session.execute( + select(FinancialStatementLine.line_code, FinancialStatementValue.column_id, FinancialStatementValue.amount) + .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).all() + by_code: dict[str, dict[int, Decimal | None]] = {} + for code, column_id, amount in rows: + by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount + return [index for _, index in columns], list(by_code.items()) + + +async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]: + rows = await session.execute( + select(FinancialStatementValue.is_modified, FinancialStatementValue.is_recognized, FinancialStatementValue.confidence) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] +``` + +Примечание к порядку строк для подстрок §7: `statement_amounts` сортирует по `line_code` — пятизначные коды идут сразу +за своим четырёхзначным родителем (1150, 11501, 11502, 1160), этого достаточно правилу M-9 (`_subline_rules`). + +- [ ] **Step 4: `result_mapping.py`** + +1. `NO_DOCUMENTS = "в результате нет документов"`; в `_load_result` и `map_file` проверка + `if not result.documents: raise MappingFatal(NO_DOCUMENTS)` вместо проверки `_osv_documents`. Docstring модуля + (пункт про `MappingFatal`) — «нет документов». +2. `_clear_file`: перед удалением документов вызвать `await statements_repo.delete_file_statements(session, file_id)`. +3. В `map_file` после цикла по листам ОСВ и до `session.add_all(documents)` — сбор форм; после вставки строк ОСВ — вставка + форм. Код ветки (вставить в `map_file`, `statement_docs` — `[d for d in result.documents if isinstance(d, StatementDocument)]`): + +```python + rules = load_check_rules() + forms: list[tuple[Document, StatementDraft]] = [] + seen_forms: set[DocumentType] = set() + other_locators: dict[DocumentType, list[str]] = {} + for doc in statement_docs: + doc_type = DocumentType(doc.document_type) + if doc_type in (DocumentType.DISCLOSURE, DocumentType.OTHER): + other_locators.setdefault(doc_type, []).append(doc.locator) + continue + if doc_type in seen_forms: + warning = f"{doc.locator}: вторая форма {doc_type.value} в файле пропущена (OQ-77)" + warnings.append(warning) + first = next(document for document, _ in forms if document.document_type is doc_type) + first.status = DocumentStatus.NEEDS_CHECK + continue + seen_forms.add(doc_type) + draft = build_statement(doc) + warnings.extend(draft.warnings) + check = counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + osv_account_code=None, + period_year=draft.period_year, + period_quarter=draft.period_quarter, + is_cumulative=None, + reporting_date=draft.period_date, + locator=doc.locator, + status=DocumentStatus.NEEDS_CHECK, # уточняется после вставки значений (нужны проверки по суммам) + period_check=DocumentCheckResult.UNKNOWN, + counterparty_check=check, + created_at=now, + updated_at=now, + ) + forms.append((document, draft)) + for doc_type, locators in other_locators.items(): + documents.append( + Document( + id=uuid.uuid4(), stored_file_id=file.id, legal_entity_id=file.legal_entity_id, document_type=doc_type, + locator=",".join(locators), status=DocumentStatus.RECOGNIZED, period_check=DocumentCheckResult.UNKNOWN, + created_at=now, updated_at=now, + ) + ) + documents.extend(document for document, _ in forms) +``` + +После `session.add_all(documents)`, `flush` и вставки строк ОСВ: + +```python + for document, draft in forms: + meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft) + column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + if document.status is not DocumentStatus.NEEDS_CHECK or not any( + w.startswith(f"{draft.locator}: вторая форма") for w in warnings + ): + document.status = statement_status( + job_done=job_done, + checks_passed=not report.failed, + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + values_ok=values_ok(await statements_repo.value_flags(session, meta.id)), + has_lines=bool(draft.lines), + warnings=draft.warnings, + ) +``` +Проще и надёжнее хранить признак «у формы был повтор» в словаре `duplicated: set[DocumentType]` и передавать в +`statement_status` `warnings=[*draft.warnings, *(["повтор формы"] if document.document_type in duplicated else [])]` — +так и сделать вместо разбора строк предупреждений (код выше — иллюстрация места вставки). +`job.group_schema` по-прежнему строится только из листов ОСВ (`sheets`). Импорты: `StatementDocument` из +`src.contract.result`; `build_statement`, `statement_status`, `values_ok`, `StatementDraft` из `src.domain.statement_mapping`; +`run_checks`, `load_check_rules` из `src.domain.statement_checks`; `from src.repositories import statements as statements_repo`. + +Если в `map_file` листов ОСВ нет, `sheets` пуст — цикл ОСВ не выполняется, `entries` пуст, вставка строк ОСВ пропускается +(проверить, что `insert(...).values([])` не вызывается). + +- [ ] **Step 5: Тесты** — `cd api && .venv/bin/pytest tests/test_mapping_statements.py tests/test_result_mapping.py tests/test_mapping_golden.py -q -p no:cacheprovider && cd .. && make lint` → PASS. `test_mapping_golden` (ОСВ) не должен измениться: снимок `st04_mixed_book` в `expected_osv_parse` не лежит. + +- [ ] **Step 6: Commit** + +```bash +git add api/src/repositories/statements.py api/src/services/result_mapping.py api/tests/test_mapping_statements.py api/tests/test_result_mapping.py +git commit -m "feat(statements): маппинг форм ББ/ОФР в map_file — meta, колонки, строки, значения, статус (D-1…D-3, D-6)" +``` + +--- + +### Task 5: API формы — `GET /documents/{id}/statement` + +**Files:** +- Create: `api/src/schemas/statements.py`, `api/src/services/statements.py`, `api/src/api/endpoints/files/statements.py` +- Modify: `api/src/repositories/statements.py` (чтение формы, пара для межформенной), `api/src/api/endpoints/files/__init__.py` (роутер), `api/src/api/dependencies/files.py` (`StatementServiceDep`), `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_statements.py` (новый) + +**Interfaces:** +- Consumes: `run_checks`, `cross_form_check`, `load_check_rules`, `FailedCheck` (Task 1); `edition_for` (Task 2); `statement_amounts` (Task 4); `files_repo.get_document` (видимость). +- Produces: + - Схемы (`src/schemas/statements.py`): `HeaderFieldOut(value: str | None, original_value: str | None, confidence: Decimal | None = None, verified: bool | None, modified: bool | None)`; `StatementHeaderOut(inn: HeaderFieldOut, report_date: HeaderFieldOut, units: HeaderFieldOut, organization_name, okud, okpo, okopf, okfs, address: str | None)`; `ColumnOut(id, column_index, label, period_date, is_current)`; `ValueOut(id, column_id, column_index, amount: Decimal | None, string_value, confidence, is_recognized, is_modified, original_amount, modified_by, modified_at)`; `LineOut(id, line_code, line_code_confidence, line_name, line_name_confidence, values: list[ValueOut])`; `FailedCheckOut(column_index: int, relation: str, expected: Decimal, actual: Decimal)`; `AcceptedOut(by: uuid.UUID, at: datetime, comment: str)`; `ChecksOut(failed: list[FailedCheckOut], soft_failed: list[FailedCheckOut], cross_form: FailedCheckOut | None, accepted: AcceptedOut | None)`; `StatementDocumentOut(id, document_type, status, period_year, period_quarter, reporting_date, counterparty_check, locator, file_id)`; `StatementOut(document: StatementDocumentOut, edition: str | None, header: StatementHeaderOut, columns: list[ColumnOut], lines: list[LineOut], checks: ChecksOut)`. + - `StatementService(session, audit: RequestAudit, scope: VisibilityScope)`: `async def get(document_id) -> StatementOut`; служебные `async def _visible_statement(document_id) -> tuple[Document, FinancialStatementMeta]` (404 / 409 `DOCUMENT_NOT_STATEMENT`), `async def build_out(document, meta) -> StatementOut`, `async def checks_report(document, meta) -> tuple[ChecksReport, FailedCheck | None]`. + - Коды ошибок (константы модуля): `DOCUMENT_NOT_STATEMENT`, `DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `NOTHING_TO_ACCEPT`, `INVALID_INN` (используются в Task 6). + - `repositories/statements.py`: `async def get_meta(session, document_id) -> FinancialStatementMeta | None`; `async def load_form(session, meta_id) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]` (строки по `line_code`, значения по `column_index`); `async def pair_amounts(session, document: Document) -> tuple[tuple[Decimal | None, Decimal | None], Decimal | None]` — для межформенной: `(1370 на отчётную дату, 1370 на 31.12 прошлого года)` из ББ и `2400` текущей колонки ОФР (D-5; пара — документ другого типа формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`, подтверждённый первым, затем последний по `created_at`). + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_statements.py` (часть GET; Task 6 и 7 допишут правку и подтверждение): + +```python +"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы», +«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" + +import json +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentType +from src.models.files import Document +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" # только DOCUMENT_VIEW +ROSTOV = "Ростовский региональный филиал" +MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" +MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + + +async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name=name, inn=inn, group=group) + + +async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document: + entity = await rostov_entity(session, fixtures_dir / path) + documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None) + return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)) + + +async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == 200, response.text + body = response.json() + assert body["edition"] == "fsbu4" + assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" + assert len(body["lines"]) == 37 + codes = [line["line_code"] for line in body["lines"]] + assert codes == sorted(codes) + columns = len(body["columns"]) + assert all(len(line["values"]) == columns for line in body["lines"]) + assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None + assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} + + +async def test_statement_of_osv_is_conflict(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + from tests.factories import make_document, make_stored_file + from src.models.enums import FileStatus, RecognitionStatus + + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ") + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done) + osv = await make_document(seeded, file=file, job=job) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{osv.id}/statement") + assert response.status_code == 409 and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" + + +async def test_statement_hidden_is_404(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client("client_manager@armka.local") # другой филиал + assert (await client.get(f"/documents/{document.id}/statement")).status_code == 404 + + +async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE) + balance = next( + d for d in await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + ) + await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{balance.id}/statement")).json() + cross = body["checks"]["cross_form"] + assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400" +``` + +Доменные ошибки отдаются как `{"detail": {"code", "message", …}}` (CLAUDE.md ядра, «Ошибки»): код — `response.json()["detail"]["code"]`. + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider`. + +- [ ] **Step 3: Репозиторий** — дописать в `src/repositories/statements.py`: + +```python +async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def load_form( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]: + columns = list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).scalars() + ) + order = {column.id: column.column_index for column in columns} + lines = list( + ( + await session.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).scalars() + ) + values = ( + await session.execute( + select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_([line.id for line in lines])) + ) + ).scalars() + by_line: dict[uuid.UUID, list[FinancialStatementValue]] = {line.id: [] for line in lines} + for value in values: + by_line[value.line_id].append(value) + for items in by_line.values(): + items.sort(key=lambda value: order[value.column_id]) + return columns, [(line, by_line[line.id]) for line in lines] +``` + +`pair_amounts` — реализация по Interfaces: найти пару запросом `select(Document).join(StoredFile).where(Document.legal_entity_id +== document.legal_entity_id, Document.period_year == document.period_year, Document.period_quarter == +document.period_quarter, Document.document_type == <другой тип>, StoredFile.status != FileStatus.SUPERSEDED) +.order_by((Document.status == DocumentStatus.CONFIRMED).desc(), Document.created_at.desc()).limit(1)`; затем у ББ взять +значения строки `1370` в колонке `is_current` и в колонке с `period_date = date(year − 1, 12, 31)`; у ОФР — строку `2400` +в колонке `is_current`. Нет пары, строки или колонки — соответствующий `None`. Если сам `document` — ОФР, роли меняются. + +- [ ] **Step 4: Схемы, сервис, маршрут** + +`src/services/statements.py` — каркас (методы правки и акцепта добавит Task 6): + +```python +"""Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14).""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.policy import VisibilityScope +from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks +from src.domain.statement_mapping import edition_for +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.statements import FinancialStatementMeta +from src.repositories import files as files_repo +from src.repositories import statements as repo +from src.schemas import statements as schemas + +DOCUMENT_NOT_FOUND = "документ не найден" +DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" +DOCUMENT_READONLY = "DOCUMENT_READONLY" +UNKNOWN_CELL = "UNKNOWN_CELL" +DUPLICATE_CELL = "DUPLICATE_CELL" +NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" +INVALID_INN = "INVALID_INN" +FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) + + +class StatementService: + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: + document = await files_repo.get_document(self._session, self._scope, document_id) + if document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + meta = await repo.get_meta(self._session, document.id) if document.document_type in FORM_TYPES else None + if meta is None: + raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") + return document, meta + + async def checks_report(self, document: Document, meta: FinancialStatementMeta) -> tuple[ChecksReport, FailedCheck | None]: + rules = load_check_rules() + column_indexes, amounts = await repo.statement_amounts(self._session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + balance_1370, income_2400 = await repo.pair_amounts(self._session, document) + return report, cross_form_check(balance_1370, income_2400, rules.tolerance) + + async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: + document, meta = await self._visible_statement(document_id) + return await self.build_out(document, meta) + + async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut: + columns, lines = await repo.load_form(self._session, meta.id) + report, cross = await self.checks_report(document, meta) + return schemas.statement_out( + document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date) + ) +``` + +`src/schemas/statements.py` — модели из Interfaces (`BaseModel`, `ConfigDict(from_attributes=True)` где строятся из ORM) +и функция `statement_out(document, meta, columns, lines, report, cross, *, edition) -> StatementOut`, собирающая ответ: +`header.inn = HeaderFieldOut(value=meta.inn, original_value=meta.inn_original_value, confidence=meta.inn_confidence, +verified=meta.inn_verified, modified=meta.inn_modified)`, `header.report_date` — `value` = `meta.report_date.isoformat()` +или `None`, `original_value` = `meta.report_date_original_value`; `header.units` аналогично; `values[].column_index` — +по `column_id`; `checks.accepted` — `AcceptedOut`, если `meta.checks_accepted_at` не пуст. Суммы — `Decimal` (в JSON +строкой, как `EntryOut`). + +`src/api/endpoints/files/statements.py` — `router = APIRouter(prefix="/documents")`, маршрут +`GET /{document_id}/statement` (`response_model=StatementOut`, `dependencies=[Depends(require("DOCUMENT_VIEW"))]`, +`responses` 404/409), вызывающий `service.get`. Подключить роутер в `src/api/endpoints/files/__init__.py` рядом с +`documents.router`; `StatementServiceDep` в `src/api/dependencies/files.py` — как `DocumentServiceDep`. В +`tests/test_openapi_routes.py` добавить `"/api/v1/documents/{document_id}/statement": {"get", "patch"}` и +`"/api/v1/documents/{document_id}/statement/checks/accept": {"post"}` (PATCH и accept появятся в Task 6 — до тех пор +временно `{"get"}` и без accept; Task 6 дополняет). + +- [ ] **Step 5: Тесты** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS. + +- [ ] **Step 6: Commit** + +```bash +git add api/src/schemas/statements.py api/src/services/statements.py api/src/api/endpoints/files api/src/api/dependencies/files.py api/src/repositories/statements.py api/tests/test_api_statements.py api/tests/test_openapi_routes.py +git commit -m "feat(statements): GET /documents/{id}/statement — шапка, колонки, строки, проверки §7, редакция (D-5, D-14)" +``` + +--- + +### Task 6: Правка формы и акцепт проверок + +**Files:** +- Modify: `api/src/services/statements.py` (`patch`, `accept_checks`, `recompute_status`), `api/src/schemas/statements.py` (`StatementPatch`, `HeaderPatch`, `CellPatch`, `ChecksAcceptIn`), `api/src/api/endpoints/files/statements.py` (PATCH, POST accept), `api/src/repositories/statements.py` (`lock_cells`), `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_statements.py` (дописать) + +**Interfaces:** +- Consumes: `StatementService._visible_statement`, `build_out`, `checks_report` (Task 5); `files_repo.lock_file`, `files_repo.lock_document`; `statement_status`, `values_ok`, `quarter_of` (Task 2); `counterparty_check` (Task 2); `CounterpartyIndex.owner_names` (существующий, `repositories/counterparties.py`); `RequestAudit.record`. +- Produces: + - Схемы: `CellPatch(line_id: uuid.UUID, column_id: uuid.UUID, amount: Decimal | None = Field(max_digits=18, decimal_places=2))`; `HeaderPatch(inn: str | None = None, report_date: date | None = None, units: str | None = Field(None, min_length=1, max_length=50))` — поле отсутствует ≠ `null` (`model_fields_set`); `StatementPatch(header: HeaderPatch | None = None, values: list[CellPatch] = [])`; `ChecksAcceptIn(comment: str = Field(min_length=1, max_length=2000))` (после `strip`). + - `StatementService.patch(document_id, body: StatementPatch) -> StatementOut`; `StatementService.accept_checks(document_id, body: ChecksAcceptIn) -> StatementOut`; `StatementService.recompute_status(document, meta) -> None` (D-6 с учётом акцепта; `CONFIRMED` не трогает); `StatementService.hard_failures(document, meta) -> tuple[FailedCheck, ...]` (жёсткие нарушения, пустой кортеж при акцепте) — используется Task 7. + - `repo.lock_cells(session, meta_id, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]` — значения этого `meta_id` по `(line_id, column_id)`; чужие ячейки в словарь не попадают. + +- [ ] **Step 1: Падающие тесты** — дописать в `api/tests/test_api_statements.py`: + +```python +from decimal import Decimal + +from src.models.audit_ex import AuditEvent +from src.models.enums import AuditAction, DocumentStatus +from src.models.statements import FinancialStatementMeta, FinancialStatementValue + + +async def _first_cell(client, document_id) -> tuple[dict, dict]: + body = (await client.get(f"/documents/{document_id}/statement")).json() + line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None) + return line, line["values"][0] + + +async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + original = cell["amount"] + new = str(Decimal(original) + 20) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]}, + ) + assert response.status_code == 200, response.text + edited = next(l for l in response.json()["lines"] if l["id"] == line["id"])["values"][0] + assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original) + assert edited["is_modified"] is True and edited["modified_by"] is not None + event = ( + await seeded.execute( + select(AuditEvent).where(AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT) + ) + ).scalars().one() + assert event.before_value and event.after_value + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]}, + ) + reverted = next(l for l in response.json()["lines"] if l["id"] == line["id"])["values"][0] + assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original) + + +async def test_patch_report_date_recomputes_period(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) + body = response.json() + assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True + assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут + + +async def test_patch_inn_validates_and_rechecks(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}}) + assert bad.status_code == 422 and bad.json()["detail"]["code"] == "INVALID_INN" + other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}}) + assert other.json()["document"]["counterparty_check"] == "MISMATCH" + assert other.json()["document"]["status"] == "NEEDS_CHECK" + + +async def test_patch_duplicate_cell_rejected(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """Review Focus 1.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"} + response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}) + assert response.status_code == 422 and response.json()["detail"]["code"] == "DUPLICATE_CELL" + + +async def test_patch_amount_precision_rejected(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + for amount in ("1.234", "12345678901234567"): + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]}, + ) + assert response.status_code == 422 + + +async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + import uuid + + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]}, + ) + assert response.status_code == 422 and response.json()["detail"]["code"] == "UNKNOWN_CELL" + + +async def test_patch_requires_edit_right(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(RKA) + assert (await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})).status_code == 403 + + +async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == 200 + count = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all() + assert count == [] + + +async def _break_balance(seeded: AsyncSession, client, document_id) -> None: + body = (await client.get(f"/documents/{document_id}/statement")).json() + line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600") + cell = line_1600["values"][0] + await client.patch( + f"/documents/{document_id}/statement", + json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"] or 0) + 1000)}]}, + ) + + +async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "ок"}) + assert nothing.status_code == 409 and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + await _break_balance(seeded, client, document.id) + broken = (await client.get(f"/documents/{document.id}/statement")).json() + assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"]) + assert broken["document"]["status"] == "NEEDS_CHECK" + accepted = await client.post( + f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление в форме клиента"} + ) + assert accepted.status_code == 200 + assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента" + await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + after = (await client.get(f"/documents/{document.id}/statement")).json() + assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK" + + +async def test_patch_confirmed_readonly(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200 + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == 409 and response.json()["detail"]["code"] == "DOCUMENT_READONLY" +``` + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider` (новые тесты — 405/404 до реализации). + +- [ ] **Step 3: Схемы** — в `src/schemas/statements.py`: + +```python +class CellPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + line_id: uuid.UUID + column_id: uuid.UUID + amount: Decimal | None = Field(max_digits=18, decimal_places=2) + + +class HeaderPatch(BaseModel): + """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение.""" + + model_config = ConfigDict(extra="forbid") + inn: str | None = None + report_date: datetime.date | None = None + units: str | None = Field(None, min_length=1, max_length=50) + + +class StatementPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + header: HeaderPatch | None = None + values: list[CellPatch] = Field(default_factory=list, max_length=5000) + + +class ChecksAcceptIn(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + comment: str = Field(min_length=1, max_length=2000) +``` + +- [ ] **Step 4: Сервис** — в `StatementService` (`src/services/statements.py`): + +```python + async def _editable(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: + """Видимость → блокировки «файл → документ» → проверки D-7 (порядок 409 как у подтверждения).""" + visible, _ = await self._visible_statement(document_id) + file = await files_repo.lock_file(self._session, visible.stored_file_id) + document = await files_repo.lock_document(self._session, document_id) + if file is None or document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + if file.status is FileStatus.SUPERSEDED: + raise ConflictError(FILE_SUPERSEDED, "файл документа заменён новой версией") + if document.status is DocumentStatus.CONFIRMED: + raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите исправленный файл") + if document.status not in CONFIRMABLE_STATUSES: + raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится") + meta = await repo.get_meta(self._session, document.id) + if meta is None: + raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") + return document, meta + + async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: + if meta.checks_accepted_at is not None: + return () + report, _ = await self.checks_report(document, meta) + return report.failed + + async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: + """D-6 после правки или акцепта; `CONFIRMED` не трогает. Предупреждения маппинга файла не учитываются.""" + if document.status is DocumentStatus.CONFIRMED: + return + job = await self._session.get(RecognitionJob, meta.recognition_job_id) + has_lines = bool((await repo.statement_amounts(self._session, meta.id))[1]) + document.status = statement_status( + job_done=job is not None and job.status is RecognitionStatus.done, + checks_passed=not await self.hard_failures(document, meta), + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + values_ok=values_ok(await repo.value_flags(self._session, meta.id)), + has_lines=has_lines, + warnings=(), + ) + document.updated_at = utc_now() + + async def patch(self, document_id: uuid.UUID, body: StatementPatch) -> StatementOut: + document, meta = await self._editable(document_id) + keys = [(cell.line_id, cell.column_id) for cell in body.values] + if len(keys) != len(set(keys)): + raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") + cells = await repo.lock_cells(self._session, meta.id, keys) + if len(cells) != len(keys): + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + before: dict[str, object] = {} + after: dict[str, object] = {} + now = utc_now() + user_id = self._audit.user_id + for cell in body.values: + value = cells[(cell.line_id, cell.column_id)] + if value.amount == cell.amount: + continue + key = f"value:{value.id}" + before[key], after[key] = value.amount, cell.amount + if not value.is_modified and value.original_amount is None: + value.original_amount = value.amount + value.amount = cell.amount + value.is_modified = value.amount != value.original_amount + value.modified_by, value.modified_at = user_id, now + header = body.header + if header is not None: + fields = header.model_fields_set + if "inn" in fields and header.inn != meta.inn: + if header.inn is not None and not (header.inn.isdigit() and len(header.inn) in (10, 12)): + raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") + before["inn"], after["inn"] = meta.inn, header.inn + meta.inn, meta.inn_modified = header.inn, True + owner = await self._session.get(LegalEntity, document.legal_entity_id) + index = await CounterpartyIndex.load(self._session) + document.counterparty_check = counterparty_check( + meta.inn, meta.organization_name, owner.inn, index.owner_names(owner) + ) + if "report_date" in fields and header.report_date != meta.report_date: + before["report_date"], after["report_date"] = meta.report_date, header.report_date + meta.report_date, meta.report_date_modified = header.report_date, True + columns, _ = await repo.load_form(self._session, meta.id) + period = statement_period(meta.report_date, [column.period_date for column in columns]) + document.reporting_date = period + document.period_year = period.year if period is not None else None + document.period_quarter = quarter_of(period) if period is not None else None + if "units" in fields and header.units is not None and header.units != meta.units: + before["units"], after["units"] = meta.units, header.units + meta.units, meta.units_modified = header.units, True + if not after: + return await self.build_out(document, meta) + if meta.checks_accepted_at is not None: + before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at} + after["checks_accepted"] = None + meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None + await self._session.flush() + await self.recompute_status(document, meta) + await self._audit.record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type=OBJECT_TYPE, + object_id=document.id, + before_value=before, + after_value=after, + ) + return await self.build_out(document, meta) + + async def accept_checks(self, document_id: uuid.UUID, body: ChecksAcceptIn) -> StatementOut: + document, meta = await self._editable(document_id) + if not await self.hard_failures(document, meta): + raise ConflictError(NOTHING_TO_ACCEPT, "непройденных жёстких проверок нет") + now = utc_now() + meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = self._audit.user_id, now, body.comment + await self._session.flush() + await self.recompute_status(document, meta) + await self._audit.record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type=OBJECT_TYPE, + object_id=document.id, + before_value={"checks_accepted": None}, + after_value={"checks_accepted": {"by": self._audit.user_id, "at": now, "comment": body.comment}}, + ) + return await self.build_out(document, meta) +``` +Импорты и константы: `OBJECT_TYPE = "document"`; `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`, `CONFIRMABLE_STATUSES` — +переиспользовать из `src.services.documents` (не дублировать строки); `DomainValidationError` из `src.common.exceptions`; +`utc_now` из `src.common.clock`; `CounterpartyIndex` из `src.repositories.counterparties`; `LegalEntity`, `RecognitionJob`, +модели и enum'ы. Если `CounterpartyIndex.owner_names` требует загрузки всего индекса и это дорого — допустимо собрать +имена владельца напрямую (`legal_entity.name` + его алиасы через `normalize_alias`), повторив логику `owner_names`. + +`repo.lock_cells`: + +```python +async def lock_cells( + session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] +) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: + if not cells: + return {} + line_ids = {line_id for line_id, _ in cells} + stmt = ( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementValue.line_id.in_(line_ids)) + .with_for_update(of=FinancialStatementValue) + ) + wanted = set(cells) + return { + (value.line_id, value.column_id): value + for value in (await session.execute(stmt)).scalars() + if (value.line_id, value.column_id) in wanted + } +``` + +- [ ] **Step 5: Маршруты** — в `src/api/endpoints/files/statements.py`: `PATCH /{document_id}/statement` +(`DOCUMENT_EDIT`, `response_model=StatementOut`, `responses` 404/409/422) → `service.patch`; `POST +/{document_id}/statement/checks/accept` (`DOCUMENT_EDIT`) → `service.accept_checks`. В `tests/test_openapi_routes.py`: +`"/api/v1/documents/{document_id}/statement": {"get", "patch"}`, `"/api/v1/documents/{document_id}/statement/checks/accept": {"post"}`. + +- [ ] **Step 6: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS (кроме `test_patch_confirmed_readonly`, если подтверждение формы ещё не проверяет период — тест проходит: у формы Межениновской период есть). + +- [ ] **Step 7: Commit** + +```bash +git add api/src/services/statements.py api/src/schemas/statements.py api/src/api/endpoints/files/statements.py api/src/repositories/statements.py api/tests/test_api_statements.py api/tests/test_openapi_routes.py +git commit -m "feat(statements): правка формы (значения, ИНН, дата, единицы) и акцепт проверок — аудит, пересчёт статуса (D-7, D-8)" +``` + +--- + +### Task 7: Подтверждение формы и сквозной сценарий чек-листа + +**Files:** +- Modify: `api/src/services/documents.py` (`confirm`), `api/src/api/endpoints/files/documents.py` (описание 409) +- Test: `api/tests/test_api_statements.py` (дописать), `api/tests/test_consolidation_documents.py` (форма двигает версию) + +**Interfaces:** +- Consumes: `StatementService.hard_failures` (Task 6), `repo.get_meta` (Task 5). +- Produces: коды `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (константы в `services/documents.py`); `ConflictError(CHECKS_NOT_PASSED, …, failures=[{column_index, relation, expected, actual}])`. + +- [ ] **Step 1: Падающие тесты** — дописать в `api/tests/test_api_statements.py`: + +```python +async def test_confirm_requires_checks(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(seeded, client, document.id) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == 409 and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + assert blocked.json()["detail"]["failures"] + await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"}) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == 200 and confirmed.json()["status"] == "CONFIRMED" + meta = (await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))).scalar_one() + assert meta.reviewed_by is not None and meta.reviewed_at is not None + + +async def test_confirm_after_non_quarter_date_edit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = (await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})).json() + assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK" + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == 409 and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}}) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200 + + +async def test_confirmed_form_in_checklist(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + from tests.factories import make_version + + document = await form(seeded, fixtures_dir) + entity = await seeded.get(LegalEntity, document.legal_entity_id) + version = await make_version(seeded, entities=[entity], year=2025, quarter="Q3") + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == 200 + checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE") + row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id)) + assert row["cells"][balance_index]["status"] == "CONFIRMED" +``` + +Сигнатуру `make_version` проверить в `tests/factories.py` (`sed -n '/^async def make_version/,/^ \"\"\"/p'`) и +подставить реальные параметры (периметр, год, квартал, статус `INITIATED`). В `tests/test_consolidation_documents.py` +добавить сценарий спеки «Форма двигает версию»: версия в `DOCUMENTS_PROCESSING`, `map_golden_snapshot` формы +`mezheninovskaya/…/1_Форма1_бух_09.json` с `uploaded_by` = пользователь (документ `NEEDS_CHECK` — ИНН владельца +отличается), затем вызов `on_documents_mapped` как в существующем тесте для ОСВ → версия `DATA_CORRECTION`, привязки нет. + +- [ ] **Step 2: Убедиться, что падают** — `cd api && .venv/bin/pytest tests/test_api_statements.py -q -p no:cacheprovider -k "confirm"`. + +- [ ] **Step 3: Реализация** — в `DocumentService.confirm` после проверки `CONFIRMABLE_STATUSES` и до смены статуса: + +```python + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(self._session, document.id) + if meta is not None: + if document.period_year is None or document.period_quarter is None: + raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта") + failures = await StatementService(self._session, self._audit, self._scope).hard_failures(document, meta) + if failures: + raise ConflictError( + CHECKS_NOT_PASSED, + "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", + failures=[ + {"column_index": f.column_index, "relation": f.relation, "expected": str(f.expected), "actual": str(f.actual)} + for f in failures + ], + ) +``` +и после установки `confirmed_by/at` — `meta.reviewed_by, meta.reviewed_at = self._audit.user_id, now` (если `meta`). +Импорт `StatementService` внутри метода (`from src.services.statements import StatementService # noqa: PLC0415`) — +`services/statements.py` импортирует константы из `services/documents.py` (цикл). `FORM_TYPES` — из +`services.statements`. Описание 409 маршрута `confirm` дополнить кодами. + +- [ ] **Step 4: Тесты и линт** — `cd api && .venv/bin/pytest tests/test_api_statements.py tests/test_api_documents.py tests/test_consolidation_documents.py -q -p no:cacheprovider && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add api/src/services/documents.py api/src/api/endpoints/files/documents.py api/tests/test_api_statements.py api/tests/test_consolidation_documents.py +git commit -m "feat(statements): подтверждение формы — период и проверки §7, reviewed_by; форма в чек-листе и в переходе версии (D-9)" +``` + +--- + +### Task 8: Комментарии к документу + +**Files:** +- Create: `api/src/api/endpoints/files/comments.py`, `api/src/schemas/comments.py` +- Modify: `api/src/services/documents.py` (`comments`, `add_comment`, `comments_count` в `file_out`/`document_out`), `api/src/repositories/files.py` (`comments_count_by_document`), `api/src/schemas/files.py` (`DocumentBrief.comments_count`), `api/src/schemas/documents.py` (`DocumentOut.comments_count`), `api/src/services/primary_files.py` (передача счётчиков в `file_out`), `api/src/api/endpoints/files/__init__.py`, `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_comments.py` + +**Interfaces:** +- Produces: `CommentIn(text: str = Field(min_length=1, max_length=4000))` со `str_strip_whitespace`; `CommentAuthorOut(id, full_name)`; `CommentOut(id, text, author: CommentAuthorOut, created_at)`; `DocumentService.add_comment(document_id, body) -> CommentOut`, `DocumentService.comments(document_id) -> list[CommentOut]`; `files_repo.comments_count_by_document(session, document_ids) -> dict[uuid.UUID, int]`; `COMMENT_OBJECT_TYPE = "document"`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_comments.py`: + +```python +"""Комментарии к документу (spec documents-api «Комментарии к документу», «Файлы организации и карточка файла»; D-11).""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import DocumentStatus, FileStatus, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_stored_file +from tests.test_api_documents import KA_RF, RKA, _rostov_entity + +pytestmark = pytest.mark.integration + + +async def _doc(session: AsyncSession, status: DocumentStatus = DocumentStatus.CONFIRMED): + entity = await _rostov_entity(session, name="ООО Комментарии") + file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done) + return file, await make_document(session, file=file, job=job, status=status) + + +async def test_add_and_list(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + file, document = await _doc(seeded) + client = await auth_client(KA_RF) + created = await client.post(f"/documents/{document.id}/comments", json={"text": " нет подписи руководителя "}) + assert created.status_code == 201, created.text + assert created.json()["text"] == "нет подписи руководителя" + assert created.json()["author"]["full_name"] + await client.post(f"/documents/{document.id}/comments", json={"text": "второй"}) + listed = (await client.get(f"/documents/{document.id}/comments")).json() + assert [item["text"] for item in listed] == ["нет подписи руководителя", "второй"] + card = (await client.get(f"/files/{file.id}")).json() + assert card["documents"][0]["comments_count"] == 2 + assert (await client.get(f"/documents/{document.id}")).json()["comments_count"] == 2 + + +async def test_validation_and_rights(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, document = await _doc(seeded) + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/comments", json={"text": " "})).status_code == 422 + assert (await client.post(f"/documents/{document.id}/comments", json={"text": "x" * 4001})).status_code == 422 + reader = await auth_client(RKA) + assert (await reader.post(f"/documents/{document.id}/comments", json={"text": "можно?"})).status_code == 403 + assert (await reader.get(f"/documents/{document.id}/comments")).status_code == 200 + stranger = await auth_client("client_manager@armka.local") + assert (await stranger.get(f"/documents/{document.id}/comments")).status_code == 404 +``` + +- [ ] **Step 2: Убедиться, что падают.** + +- [ ] **Step 3: Реализация.** Модель `Comment` — `src/models/audit_ex.py` (поля `object_type`, `object_id`, `text`, +`author_id`, `created_at`). `add_comment`: видимость `_visible` → `Comment(object_type="document", object_id=document.id, +text=body.text, author_id=self._audit.user_id, created_at=utc_now())` → `flush` → ответ с ФИО автора (`AppUser.full_name`). +`comments`: `select(Comment, AppUser.full_name).join(AppUser, AppUser.id == Comment.author_id).where(object_type == +"document", object_id == document.id).order_by(Comment.created_at, Comment.id)`. `comments_count_by_document`: +`select(Comment.object_id, func.count()).where(Comment.object_type == "document", Comment.object_id.in_(ids)).group_by(…)`. +`file_out(file, job, documents, counts: Mapping[uuid.UUID, int] | None = None)` — `DocumentBrief(..., comments_count=counts.get(id, 0))`; +во всех местах построения карточек файла (`primary_files._cards`, `DocumentService`, загрузка) передать счётчики +(`grep -n "file_out(" src -r`). `DocumentOut.comments_count` — в `document_out`. Маршруты: `POST +/documents/{document_id}/comments` (`status_code=201`, `DOCUMENT_EDIT`), `GET …/comments` (`DOCUMENT_VIEW`, +`response_model=list[CommentOut]`). В `tests/test_openapi_routes.py` — `"/api/v1/documents/{document_id}/comments": {"get", "post"}`. + +- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_api_comments.py tests/test_api_files_read.py tests/test_api_documents.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** — `git add -A api/src api/tests && git commit -m "feat(documents): комментарии к документу и comments_count в карточке (D-11)"` (проверить `git status`, что в коммит не попало лишнее). + +--- + +### Task 9: Отдача исходного файла потоком + +**Files:** +- Modify: `api/src/core/storage.py` (`ObjectStorage.stream`), `api/src/core/clients.py` (`S3Storage.stream`), `api/tests/conftest.py` (`FakeStorage.stream`), `api/src/services/primary_files.py` (`content`), `api/src/api/endpoints/files/read.py` (маршрут), `api/src/api/endpoints/consolidations/export.py` (вынести `content_disposition` в `src/common/filenames.py`, если его там нет), `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_files_content.py`, `api/tests/test_clients.py` (поток S3Storage на фейковом клиенте) + +**Interfaces:** +- Produces: + - `ObjectStorage.stream(obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]` — асинхронный генератор; нет объекта — `ObjectNotFoundError` **до** первой порции (ошибка видна до отправки заголовков). Реализация S3: `get_object` → `response.content.iter_chunked(chunk_size)`; в `finally` — `response.close()` и `await response.release()`. + - `PrimaryFilesService.content(file_id, disposition: Literal["attachment","inline"]) -> tuple[AsyncIterator[bytes], str, str]` — поток, `Content-Type`, `Content-Disposition`; 404 `FILE_CONTENT_NOT_FOUND`, если объекта нет. + - `content_disposition(filename: str, *, disposition: str = "attachment", ascii_fallback: str) -> str` в `src/common/filenames.py`. + - MIME: `PDF` `application/pdf`, `PNG` `image/png`, `JPG` `image/jpeg`, `XLS` `application/vnd.ms-excel`, `XLSX` `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `XLSB` `application/vnd.ms-excel.sheet.binary.macroEnabled.12`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_files_content.py`: + +```python +"""Исходный файл потоком (spec documents-api «Исходный файл»; design D-10).""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.files import S3Object +from tests.conftest import AuthClientFactory, FakeStorage +from tests.test_api_documents import KA_RF, _rostov_entity +from tests.factories import make_stored_file +from src.models.enums import FileStatus, FileType, RecognitionStatus + +pytestmark = pytest.mark.integration +PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка порционной отдачи + + +async def _file(session: AsyncSession, storage: FakeStorage, name: str, file_type: FileType): + entity = await _rostov_entity(session, name="ООО Файлы") + file, _ = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, original_name=name + ) + file.file_type = file_type + key = (await session.execute(select(S3Object.key).where(S3Object.id == file.s3_object_id))).scalar_one() + storage.objects[key] = PDF + await session.flush() + return file + + +async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "aep_6.pdf", FileType.PDF) + client: AsyncClient = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content", params={"disposition": "inline"}) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" + assert response.content == PDF + + +async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "ОСВ_66 за 2024.xls", FileType.XLS) + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + assert response.headers["content-disposition"].startswith('attachment; filename="file.xls"; filename*=UTF-8\'\'%D0%9E') + storage.objects.clear() + missing = await client.get(f"/files/{file.id}/content") + assert missing.status_code == 404 and missing.json()["detail"]["code"] == "FILE_CONTENT_NOT_FOUND" + + +async def test_stream_releases_on_error() -> None: + """Review Focus 4: при обрыве потребителя ответ S3 закрывается и отпускается.""" + from src.core.clients import S3Storage + + class Content: + async def iter_chunked(self, size: int): + yield b"a" * size + yield b"b" * size + + class Response: + def __init__(self) -> None: + self.content = Content() + self.closed = self.released = False + + def close(self) -> None: + self.closed = True + + async def release(self) -> None: + self.released = True + + response = Response() + + class Client: + async def get_object(self, bucket: str, name: str) -> Response: + return response + + storage = S3Storage(Client(), "arm-ka") # type: ignore[arg-type] + stream = storage.stream("uploads/x", chunk_size=4) + assert await stream.__anext__() == b"aaaa" + await stream.aclose() # потребитель оборвал чтение + assert response.closed and response.released +``` + +Если фикстура `app` в conftest не допускает подмену `app.state.storage` после создания — сделать это так же, как в +`tests/test_api_files_upload.py` (`grep -n "state.storage" tests/test_api_files_upload.py`). + +- [ ] **Step 2: Убедиться, что падают.** + +- [ ] **Step 3: Реализация.** `S3Storage.stream`: + +```python + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + """Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве.""" + try: + response = await self.client.get_object(self.bucket, obj_name) + except S3Error as error: + if error.code == NO_SUCH_KEY: + raise ObjectNotFoundError(obj_name) from error + raise + try: + async for chunk in response.content.iter_chunked(chunk_size): + yield chunk + finally: + response.close() + await response.release() +``` +Чтобы ошибка «нет объекта» возникала до заголовков ответа, сервис вызывает `stream`, берёт первую порцию +(`await anext(iterator, b"")`) и отдаёт `StreamingResponse` из генератора «первая порция + остальные». `FakeStorage.stream` +— генератор по `self.objects[obj_name]` порциями `chunk_size`, `KeyError` → `ObjectNotFoundError`. Маршрут `GET +/files/{file_id}/content` (`DOCUMENT_VIEW`, `disposition: Literal["attachment","inline"] = "attachment"`) возвращает +`StreamingResponse(iterator, media_type=..., headers={"Content-Disposition": ...})`; файл ищется с видимостью +(`files_repo.get_file`), ключ — `S3Object.key` по `stored_file.s3_object_id`; файл `SUPERSEDED` разрешён. В +`test_openapi_routes.py` — `"/api/v1/files/{file_id}/content": {"get"}`. + +- [ ] **Step 4: Тесты** — `cd api && .venv/bin/pytest tests/test_api_files_content.py tests/test_api_osv_export.py tests/test_openapi_routes.py -q -p no:cacheprovider && cd .. && make lint` → PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(files): GET /files/{id}/content — исходный файл потоком из S3, RFC 5987 (D-10)"` (добавить изменённые файлы явно). + +--- + +### Task 10: Справочник имён пользователей + +**Files:** +- Create: `api/src/api/endpoints/users.py` +- Modify: `api/src/repositories/users.py` (`users_brief`), `api/src/schemas/admin.py` или новый `api/src/schemas/users.py` (`UserBriefOut`), `api/src/api/router.py`, `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_users_brief.py` + +**Interfaces:** +- Produces: `GET /api/v1/users/brief?ids=,` → `list[UserBriefOut(id, full_name, is_active)]`; `users_repo.users_brief(session, ids: Sequence[uuid.UUID]) -> list[AppUser]`; ошибка `TOO_MANY_IDS` (422, `DomainValidationError`), `MAX_BRIEF_IDS = 200`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_api_users_brief.py`: + +```python +"""Краткий справочник пользователей (spec users-admin «Краткий справочник пользователей»; D-12).""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from tests.conftest import AuthClientFactory + +pytestmark = pytest.mark.integration + + +async def test_brief_for_non_admin(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + users = (await seeded.execute(select(AppUser).order_by(AppUser.email).limit(2))).scalars().all() + unknown = uuid.uuid4() + client = await auth_client("ka_rf@armka.local") # без USERS_ADMIN + ids = ",".join([*(str(user.id) for user in users), str(unknown)]) + response = await client.get("/users/brief", params={"ids": ids}) + assert response.status_code == 200 + body = {item["id"]: item for item in response.json()} + assert set(body) == {str(user.id) for user in users} + assert all(set(item) == {"id", "full_name", "is_active"} for item in body.values()) + + +async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + client = await auth_client("ka_rf@armka.local") + too_many = ",".join(str(uuid.uuid4()) for _ in range(201)) + response = await client.get("/users/brief", params={"ids": too_many}) + assert response.status_code == 422 and response.json()["detail"]["code"] == "TOO_MANY_IDS" + assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == 422 +``` + +- [ ] **Step 2: Убедиться, что падают.** + +- [ ] **Step 3: Реализация.** Маршрут `router = APIRouter(prefix="/users", tags=["users"])`, `GET /brief`, зависимость +`Annotated[Principal, Depends(current_user)]` (как `me.py`), параметр `ids: str = Query(...)`; разбор — `split(",")`, +пустые пропускаются, `uuid.UUID(...)` с `ValueError` → `DomainValidationError("INVALID_IDS", "…")` (422), больше 200 — +`TOO_MANY_IDS`. Репозиторий: `select(AppUser).where(AppUser.id.in_(ids))`. Подключить `users.router` в `build_api_router` +**до** `admin.router` (пути не пересекаются: `/users/brief` против `/admin/users`). `test_openapi_routes.py` — путь +`"/api/v1/users/brief": {"get"}` и префикс `"/api/v1/users"` в `CHECKED_PREFIXES`. + +- [ ] **Step 4: Тесты и линт** — PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(users): GET /users/brief — имена пользователей для всех аутентифицированных (D-12)"`. + +--- + +### Task 11: `documents_count` у ЮЛ + +**Files:** +- Modify: `api/src/repositories/directories.py` (`documents_count_by_entity`), `api/src/services/legal_entities.py` (`page`, `get`), `api/src/schemas/directories.py` (`LegalEntityOut.documents_count: int = 0`), endpoint списка — если он строит `LegalEntityOut.model_validate(entity)` напрямую, передавать счётчик +- Test: `api/tests/test_api_legal_entities.py` (дописать) + +**Interfaces:** +- Produces: `async def documents_count_by_entity(session, entity_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]` — `count(document.id)` по `document.legal_entity_id` с `join(StoredFile)` и `StoredFile.status != SUPERSEDED`. + +- [ ] **Step 1: Падающий тест** — в `api/tests/test_api_legal_entities.py`: + +```python +async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + from src.models.enums import DocumentType, FileStatus, RecognitionStatus + from tests.factories import make_document, make_legal_entity, make_stored_file + + entity = await make_legal_entity(seeded, name="ООО Счётчик") # монозаёмщик, видим ГО + live, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done) + await make_document(seeded, file=live, job=job, account="60") + await make_document(seeded, file=live, job=job, account="62") + old, old_job = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done) + await make_document(seeded, file=old, job=old_job, account="66") + client = await auth_client("admin@armka.local") + listed = (await client.get("/directories/legal-entities", params={"q": "Счётчик"})).json() + assert listed["items"][0]["documents_count"] == 2 + card = (await client.get(f"/directories/legal-entities/{entity.id}")).json() + assert card["documents_count"] == 2 +``` +Пользователь с видимостью всех ЮЛ — подобрать из существующих тестов файла (`grep -n "auth_client(" tests/test_api_legal_entities.py | head -3`); `make_document` с разными `account` — чтобы не нарушить уникальность документа в файле. + +- [ ] **Step 2–4:** убедиться, что падает; реализовать подзапрос на страницу (`ids` страницы → словарь счётчиков) и в карточке; тесты и линт — PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(directories): documents_count в списке и карточке ЮЛ (D-13)"`. + +--- + +### Task 12: Чек-лист в CSV + +**Files:** +- Create: `api/src/domain/checklist_csv.py` +- Modify: `api/src/api/endpoints/consolidations/checklist.py` (параметр `format`), `api/src/services/consolidations.py` (имя ГК для имени файла, если не доступно в `ChecklistOut`) +- Test: `api/tests/test_domain_checklist_csv.py`, `api/tests/test_api_consolidation_checklist.py` (дописать) + +**Interfaces:** +- Produces: `render_checklist_csv(checklist: ChecklistOut) -> bytes` — UTF-8 с BOM, `;`, `\r\n`; заголовок «Организация;ИНН;<правило>…»; подписи `CELL_LABELS = {CONFIRMED: "Загружен", PROCESSING: "Обработка", NEEDS_CHECK: "Требуется проверка", RECOGNIZED: "Требуется проверка", ERROR: "Ошибка загрузки", MISSING: "Отсутствует"}`; правило: `BALANCE` «Бух. баланс», `INCOME` «ОФР», `OSV` «ОСВ », иначе `document_type`; при `missing_quarters` — « (нет: Q1, Q2)». Значения с `;` или `"` экранируются (`csv.writer` с `delimiter=";"`). `checklist_filename(group_name: str, year: int, quarter: str) -> str` = `checklist-<ГК>-<год>-<квартал>.csv`. + +- [ ] **Step 1: Падающие тесты** — `api/tests/test_domain_checklist_csv.py`: + +```python +"""CSV чек-листа (spec consolidations-api «Чек-лист комплекта»; D-13).""" + +import uuid + +from src.domain.checklist import CellStatus +from src.domain.checklist_csv import checklist_filename, render_checklist_csv +from src.models.enums import DocumentType, PeriodQuarter +from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut + + +def test_render() -> None: + checklist = ChecklistOut( + rules=[ + ChecklistRuleOut(document_type=DocumentType.BALANCE, account=None, is_mandatory=True), + ChecklistRuleOut(document_type=DocumentType.OSV, account="60", is_mandatory=True), + ], + rows=[ + ChecklistRowOut( + legal_entity_id=uuid.uuid4(), legal_entity_name='ООО "Альфа; Бета"', legal_entity_inn="7700000000", + cells=[ + ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]), + ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]), + ], + ) + ], + ) + content = render_checklist_csv(checklist) + assert content.startswith("".encode()) + lines = content.decode("utf-8-sig").splitlines() + assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60" + assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' + + +def test_filename() -> None: + assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" +``` + +В `tests/test_api_consolidation_checklist.py` — сценарий «Чек-лист в CSV»: `GET …/checklist?format=csv` → 200, +`content-type` начинается с `text/csv`, тело начинается с BOM, первая строка — заголовок, `content-disposition` содержит +`filename*=UTF-8''checklist-`. + +- [ ] **Step 2: Убедиться, что падают.** + +- [ ] **Step 3: Реализация** — `src/domain/checklist_csv.py` на `csv.writer(io.StringIO(), delimiter=";", lineterminator="\r\n")`, +результат `("" + buffer.getvalue()).encode("utf-8")`. Маршрут: параметр `format: Literal["json", "csv"] = "json"`; +при `csv` — `Response(content=render_checklist_csv(checklist), media_type="text/csv; charset=utf-8", +headers={"Content-Disposition": content_disposition(checklist_filename(...), disposition="attachment", +ascii_fallback="checklist.csv")})`; у маршрута `response_model=ChecklistOut` сохраняется для JSON (для CSV вернуть +`Response` — FastAPI пропустит валидацию). Имя ГК, год и квартал — из карточки версии (`service.get_version` или +аналог; при отсутствии метода — добавить в `ConsolidationService` лёгкий `version_title(version_id) -> tuple[str, int, str]`). + +- [ ] **Step 4: Тесты и линт** — PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(consolidations): чек-лист в CSV — подписи макета, BOM, «;» (D-13)"`. + +--- + +### Task 13: README, CLAUDE.md, tasks.md, полная проверка + +**Files:** +- Modify: `README.md` (разделы API документов и ошибок), `CLAUDE.md` (блок «Первичные документы» — формы, §7, правка; карта `domain/`), `openspec/changes/slice-3b-statements-api/tasks.md` + +- [ ] **Step 1: README** — в разделе эндпоинтов документов добавить: `GET/PATCH /documents/{id}/statement`, `POST +…/statement/checks/accept`, `POST/GET /documents/{id}/comments`, `GET /files/{id}/content`, `GET /users/brief`, +`documents_count`, `?format=csv` чек-листа; в каталоге ошибок «Ошибки API» — `DOCUMENT_NOT_STATEMENT`, `DOCUMENT_READONLY`, +`UNKNOWN_CELL`, `DUPLICATE_CELL`, `INVALID_INN` (форма), `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`, +`FILE_CONTENT_NOT_FOUND`, `TOO_MANY_IDS`, `INVALID_IDS`. Сообщение «в результате нет документов» вместо «нет документов ОСВ». +- [ ] **Step 2: CLAUDE.md** — в «Первичные документы»: формы ББ/ОФР маппятся в `map_file` (`domain/statement_mapping.py`, +`repositories/statements.py`), проверки §7 — `domain/statement_checks.py` + копия `rules/statement_checks.yaml` (меняется +в обоих репозиториях), правка формы — `services/statements.py` (только до подтверждения; аудит `RECOGNIZED_DATA_EDIT`). +- [ ] **Step 3: tasks.md** — перенести задачи 0–15 плана с отметками `[x]` по факту; `openspec validate slice-3b-statements-api --strict` → valid. +- [ ] **Step 4: Полная проверка** — `make check-full` (включает `dbml-check`, `test-integration`, `e2e`, `docker-build`, +`web-check`) → зелёный; вывод — в отчёт задачи (пойдёт в описание PR). При красном `e2e` из-за отсутствия профиля +`services` — это skip, не ошибка. +- [ ] **Step 5: Commit** — `git commit -m "docs(arm-core): README, CLAUDE.md, tasks.md под Срез 3b"`. + +--- + +### Task 14: Документы workspace (bounded-PR в docs) + +**Files (репозиторий docs, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs`):** +`context/05-domain-rules.md` (§7, §9.3, §9.5), `context/07-open-questions.md`, `context/03-architecture.md`, +`context/09-delivery-plan.md`, `dev/api-contract-slice-3b.md` (новый), `dev/README.md` (ссылка), `dev/qa-guide.md`. + +- [ ] **Step 1: Issue и worktree** + +```bash +cd /Users/andrey.epifancev/projects/awp-ca +tea issues create --login home --repo RSHB/arm-ka-docs --title "Срез 3b (docs): §7/§9 по факту, контракт API форм и мелких дыр, OQ" \ + --description "Сопровождение change arm-core slice-3b-statements-api (#36): §7 (проверки в ядре, межформенная мягкая, акцепт), §9.3/§9.5 (формы ББ/ОФР, правка до подтверждения, условия подтверждения), dev/api-contract-slice-3b.md, OQ (аудит скачивания первички), 03-architecture (копия словаря §7), 09-delivery-plan (3b реализован)." +# номер — +rm /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs # симлинк +git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs -b feat/-slice-3b-docs main +touch /Users/andrey.epifancev/projects/awp-ca/.wt/slice-3b/docs/.bounded +``` + +- [ ] **Step 2: Правки** +1. §7: «Ядро (Срез 3b) считает §7 по текущим суммам формы словарём `arm-core/api/rules/statement_checks.yaml` (копия + словаря tabular-parser): те же жёсткие и мягкие соотношения, допуск ±1, округление 0,001 %, `abs_terms`, подстроки. + Межформенная `1370 − 1370(начало года) = 2400` — мягкая (не блокирует подтверждение). Акцепт — с комментарием, + сбрасывается любой правкой формы.» +2. §9.3: статус документа формы — правило D-6 спеки 3b; §9.5 п. 2 — формы маппятся (вместо «игнорируются»), период ББ/ОФР + по дате отчёта или самой поздней дате колонок; новый пункт «Правка формы» — значения и ИНН/дата/единицы только до + подтверждения, исправление после — новым файлом; п. 4 — условия подтверждения формы (`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`). +3. `07-open-questions.md`: новый OQ (следующий номер после последнего): «Журналировать ли скачивание первички и CSV + (ТЗ 8.2 „выгрузка“)? Сейчас не пишется, как экспорт сводной ОСВ» — допущение принято; OQ-23, OQ-36, OQ-77, OQ-140, + OQ-145 — дописать «Срез 3b: допущение реализовано». +4. `03-architecture.md`: раздел Среза 3b (маппинг форм в `map_file`, `statement_checks.yaml` — копия, риск расхождения). +5. `09-delivery-plan.md`: Срез 3 → 3a закрыт, 3b реализован (бэкенд и API, UI отдельно); статусы 2a и 2c — «закрыт» (сейчас «ожидает merge/ревью»). +6. `dev/api-contract-slice-3b.md` — по образцу `dev/api-contract-slice-4.md`: общее (коды ошибок, формат денег), + эндпоинты (таблица), схемы (`StatementOut`, `StatementPatch`, `CommentOut`, `UserBriefOut`), нюансы (подтверждённая + форма только для чтения; `is_modified` снимается возвратом к исходному; акцепт сбрасывается правкой; редакция формы; + CSV — BOM и «;»), сквозной сценарий экрана «Проверка распознавания». Ссылка в `dev/README.md`. +7. `dev/qa-guide.md`: как проверить форму на стенде (загрузка `fixtures/statements-xlsx/mezheninovskaya/...`, просмотр + формы, правка, акцепт, подтверждение, чек-лист). + +- [ ] **Step 3: Проверка, коммит, PR** — `make validate-contracts` в worktree docs; коммит +`docs(context): Срез 3b — §7/§9 по факту, контракт API форм, OQ (#)`; push; `tea pulls create --login home --repo +RSHB/arm-ka-docs --head feat/-slice-3b-docs --base main` с «Closes #», списком «Rulings I made» и атрибуцией. + +--- + +### Task 15: Завершение — финальное ревью и PR + +- [ ] **Step 1:** `git fetch origin && git merge --no-edit origin/main` (force-push запрещён), `make check-full`, +`openspec validate slice-3b-statements-api --strict`. +- [ ] **Step 2:** финальное ревью всей ветки (opus): `git diff origin/main...HEAD`; исправление critical/important; +повторный `make check-full`. +- [ ] **Step 3:** PR arm-core: `tea pulls create --login home --repo RSHB/arm-core --head feat/36-slice-3b-statements-api +--base main --title "Срез 3b: формы ББ/ОФР в ядре и API до сводной ОСВ"` с «Closes #36», ссылкой на change и PR docs, +выводом `make check-full`, таблицей всех находок (маркер, суть, статус), «Rulings I made», deferred, атрибуцией. +- [ ] **Step 4:** отчёт: ссылки на PR, находки, рулинги, deferred, что осталось владельцу (мерж docs и arm-core; OQ об аудите скачивания). -- 2.47.2 From 590d4ebc09d658503fa58ffa0198d88ca750678c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 10:57:25 +0400 Subject: [PATCH 03/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9611=E2=80=9312=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=203b=20=E2=80=94=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B5?= =?UTF-8?q?,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 75fa278..0ab5b7a 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -170,3 +170,5 @@ seed-dev` с нуля. | 8 | 2026-10-07 | Скачивание файла, CSV, комментарии — без аудита | паритет с экспортом xlsx; `comment` хранит автора | журнал не покажет скачивания (OQ в docs) | | 9 | 2026-10-07 | `GET /users/brief` без отдельного права | имена нужны всем экранам; данные — только ФИО и активность | ФИО видны всем аутентифицированным | | 10 | 2026-10-07 | Нет теста идентичности словаря §7 с tabular-parser | урок #33: межрепозиторные тесты ломают main | копии разойдутся незаметно | +| 11 | 2026-10-07 | R-1: признак «повтор формы в файле» при маппинге хранится множеством типов форм и передаётся в `statement_status` как предупреждение; первый документ типа — `NEEDS_CHECK` | поведение по спеке («Повтор формы в файле»); фрагмент плана с разбором строк предупреждений — иллюстрация | — | +| 12 | 2026-10-07 | R-2: пересчёт статуса формы после правки и акцепта не учитывает предупреждения маппинга (`warnings = ()`) | предупреждения маппинга в БД хранятся только на уровне файла (`metrics.mapping_warnings`) без привязки к документу; хранить их отдельно — новое поле, DBML v5.17 | документ с предупреждением маппинга (повтор кода, значение вне колонок, пропущенная вторая форма) после правки аналитиком может стать `RECOGNIZED`; аналитик форму уже просмотрел | -- 2.47.2 From 6eb708f76095376484b9945e40fe773f17d9a4b2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:01:11 +0400 Subject: [PATCH 04/36] =?UTF-8?q?feat(statements):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=C2=A77=20=D0=B2=20=D1=8F?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=20tabular-parser,=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D1=8C=20statement=5Fchecks.yaml=20(D-4,=20D-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/rules/statement_checks.yaml | 29 +++ api/src/domain/statement_checks.py | 222 ++++++++++++++++++++++ api/tests/test_domain_statement_checks.py | 109 +++++++++++ 3 files changed, 360 insertions(+) create mode 100644 api/rules/statement_checks.yaml create mode 100644 api/src/domain/statement_checks.py create mode 100644 api/tests/test_domain_statement_checks.py diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml new file mode 100644 index 0000000..317466c --- /dev/null +++ b/api/rules/statement_checks.yaml @@ -0,0 +1,29 @@ +# КОПИЯ tabular-parser/api/rules/statement_checks.yaml (Срез 3b, design D-4): меняется в обоих репозиториях одновременно. +# Межрепозиторного теста идентичности нет сознательно (решение №10); расхождение копий — известный риск (03-architecture). +# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь. +# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check +# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в +# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7). +tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно) +# OQ-138 (решение владельца 06.10.2026): единицы шапки верны, расхождение до 0,001 % итоговой строки — округление. +# Жёсткое соотношение с расхождением больше `tolerance`, но не больше этой доли от наибольшего по модулю числа +# соотношения (итог или слагаемое: итог бывает малой разностью больших слагаемых), не портит статус: +# уходит в soft_relations_failed с предупреждением «в пределах округления». +rounding_tolerance_percent: 0.001 +subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду +relations: + - {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true} + - {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true} + - {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true} + - {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true} + - {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true} + - {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true} + - {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true} + - {form: BALANCE, relation: "1600 = 1700", strict: true} + - {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]} + - {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]} + - {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]} + - {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]} + - {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false} + - {form: INCOME, relation: "2410 = 2411 + 2412", strict: false} + - {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false} diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py new file mode 100644 index 0000000..a580eb5 --- /dev/null +++ b/api/src/domain/statement_checks.py @@ -0,0 +1,222 @@ +"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). + +Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в +колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по +модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше +`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = +четырёхзначный родитель» — мягкая. Результат не хранится (DBML). +""" + +import functools +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Literal, cast + +import yaml + +__all__ = [ + "CROSS_FORM_RELATION", + "CheckRules", + "ChecksReport", + "FailedCheck", + "RelationRule", + "cross_form_check", + "load_check_rules", + "run_checks", +] + +Form = Literal["BALANCE", "INCOME"] +RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml" +CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400" +# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser — +# statement_headers.yaml) +CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)} +_ZERO = Decimal("0") +_HUNDRED = Decimal("100") +_PARENT_LEN = 4 +_GROUP_LEN = 3 +_RELATION = re.compile(r"^\s*(?P\d{4})\s*=\s*(?P\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$") +_TERM = re.compile(r"([+\-])?\s*(\d{4,5})") + + +@dataclass(frozen=True) +class RelationRule: + form: Form + relation: str + target: str + terms: tuple[tuple[int, str], ...] + strict: bool + abs_terms: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class CheckRules: + tolerance: Decimal + rounding_tolerance_percent: Decimal + subline_check: bool + relations: tuple[RelationRule, ...] + + def relations_for(self, form: str) -> tuple[RelationRule, ...]: + return tuple(rule for rule in self.relations if rule.form == form) + + +@dataclass(frozen=True) +class FailedCheck: + column_index: int + relation: str + expected: Decimal + actual: Decimal + + +@dataclass(frozen=True) +class ChecksReport: + failed: tuple[FailedCheck, ...] + soft_failed: tuple[FailedCheck, ...] + + +def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule: + found = _RELATION.match(relation) + if found is None: + raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …»") + terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs"))) + unknown = set(abs_terms) - {code for _, code in terms} + if unknown: + raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»") + return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms)) + + +@functools.lru_cache(maxsize=4) +def load_check_rules(path: Path | None = None) -> CheckRules: + """Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла.""" + source = path or RULES_PATH + try: + data = yaml.safe_load(source.read_text(encoding="utf-8")) + relations = tuple( + _parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ())) + for item in data["relations"] + ) + for rule in relations: + if rule.form not in CODE_RANGES: + raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME") + return CheckRules( + tolerance=Decimal(str(data["tolerance"])), + rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))), + subline_check=bool(data.get("subline_check", True)), + relations=relations, + ) + except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc: + raise ValueError(f"словарь проверок {source.name}: {exc}") from exc + + +def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal: + total = _ZERO + for sign, code in rule.terms: + value = amounts.get(code, _ZERO) + if absolute and code in rule.abs_terms: + value = abs(value) + total += sign * value + return total + + +def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]: + expected = amounts.get(rule.target, _ZERO) + plain = _rhs(amounts, rule, absolute=False) + if abs(expected - plain) <= tolerance: + return True, expected, plain + if rule.abs_terms: + with_abs = _rhs(amounts, rule, absolute=True) + if abs(expected - with_abs) <= tolerance: + return True, expected, with_abs + if abs(expected - with_abs) < abs(expected - plain): + return False, expected, with_abs + return False, expected, plain + + +def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: + """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по + порядку строк, M-9 2b).""" + low, high = CODE_RANGES[form] + parents = {code for code in codes if len(code) == _PARENT_LEN} + children: dict[str, list[str]] = {} + last_parent: str | None = None + for code in codes: + if len(code) == _PARENT_LEN: + last_parent = code + continue + if len(code) != _PARENT_LEN + 1: + continue + if last_parent is not None and last_parent[:_GROUP_LEN] == code[:_GROUP_LEN]: + parent = last_parent + elif code[:_PARENT_LEN] in parents: + parent = code[:_PARENT_LEN] + else: + continue + children.setdefault(parent, []).append(code) + rules: list[RelationRule] = [] + for parent, kids in children.items(): + if parent.isdigit() and low <= int(parent) <= high: + relation = f"{parent} = {' + '.join(kids)}" + rules.append( + RelationRule( + cast(Form, form), + relation, + parent, + tuple((1, kid) for kid in kids), + False, + ) + ) + return rules + + +def run_checks( + form: str, + lines: Sequence[tuple[str, Mapping[int, Decimal | None]]], + column_indexes: Sequence[int], + rules: CheckRules, +) -> ChecksReport: + """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: + сумма})` по порядку строк.""" + explicit = list(rules.relations_for(form)) + sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else [] + hard: list[FailedCheck] = [] + soft: list[FailedCheck] = [] + for column_index in column_indexes: + amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} + for rule in [*explicit, *sublines]: + if rule not in sublines and rule.target not in amounts: + continue + # Для явных правил (не подстроки): пропустить, если целевой есть, но ни одного члена нет + if rule not in sublines: + term_codes = {code for _, code in rule.terms} + if not any(code in amounts for code in term_codes): + continue + holds, expected, actual = _holds(amounts, rule, rules.tolerance) + if holds: + continue + failed = FailedCheck(column_index, rule.relation, expected, actual) + scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)]) + rounding = scale * rules.rounding_tolerance_percent / _HUNDRED + if rule.strict and abs(expected - actual) > rounding: + hard.append(failed) + else: + soft.append(failed) + return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft)) + + +def cross_form_check( + balance_1370: tuple[Decimal | None, Decimal | None], + income_2400: Decimal | None, + tolerance: Decimal, +) -> FailedCheck | None: + """Межформенная §7 (мягкая, D-5): `1370(отчётная дата) − 1370(начало года) = 2400(период)`; + неполные данные — None.""" + current, year_start = balance_1370 + if current is None or year_start is None or income_2400 is None: + return None + delta = current - year_start + if abs(delta - income_2400) <= tolerance: + return None + return FailedCheck(-1, CROSS_FORM_RELATION, delta, income_2400) diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py new file mode 100644 index 0000000..682cd24 --- /dev/null +++ b/api/tests/test_domain_statement_checks.py @@ -0,0 +1,109 @@ +"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser.""" + +from decimal import Decimal + +import pytest + +from src.domain.statement_checks import ( + ChecksReport, + FailedCheck, + cross_form_check, + load_check_rules, + run_checks, +) + +D = Decimal +RULES = load_check_rules() + + +def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]: + return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items] + + +def test_rules_loaded_from_copy() -> None: + assert RULES.tolerance == D("1") + assert RULES.rounding_tolerance_percent == D("0.001") + relations = [rule.relation for rule in RULES.relations_for("BALANCE")] + assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations + income = {rule.relation: rule for rule in RULES.relations_for("INCOME")} + assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"}) + assert income["2400 = 2300 - 2410 + 2420"].strict is False + + +def test_hard_violation() -> None: + report = run_checks( + "BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES + ) + assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900")) in report.failed + + +def test_relation_skipped_without_target() -> None: + report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES) + assert report == ChecksReport(failed=(), soft_failed=()) + + +def test_tolerance_one_unit() -> None: + report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES) + assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) + + +def test_abs_terms_minus_sign() -> None: + report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES) + assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed) + + +def test_rounding_goes_soft() -> None: + # расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления» + report = run_checks( + "BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES + ) + assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed) + assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed) + + +def test_soft_relation_not_hard() -> None: + report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES) + assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed) + assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed) + + +def test_sublines_soft() -> None: + report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES) + assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed) + assert report.failed == () + + +def test_per_column_and_null_as_zero() -> None: + report = run_checks( + "BALANCE", + _lines(("1100", {0: "300", 1: None}), ("1200", {0: "600", 1: "5"}), ("1600", {0: "900", 1: "5"})), + [0, 1], + RULES, + ) + assert report.failed == () + + +@pytest.mark.parametrize( + ("b1370", "i2400", "expected"), + [ + ((D("500"), D("300")), D("200"), None), + ( + (D("500"), D("300")), + D("150"), + FailedCheck(-1, "1370 − 1370(начало года) = 2400", D("200"), D("150")), + ), + ((None, D("300")), D("200"), None), + ((D("500"), D("300")), None, None), + ], +) +def test_cross_form( + b1370: tuple[Decimal | None, Decimal | None], i2400: Decimal | None, expected: FailedCheck | None +) -> None: + assert cross_form_check(b1370, i2400, D("1")) == expected + + +def test_broken_rules_file(tmp_path) -> None: + bad = tmp_path / "statement_checks.yaml" + bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8") + with pytest.raises(ValueError, match="statement_checks.yaml"): + load_check_rules(bad) -- 2.47.2 From 4126a44e0ea0bc87ee046bb6b4f03733ab762eff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:07:54 +0400 Subject: [PATCH 05/36] =?UTF-8?q?fix(statements):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=20=D1=8F=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D1=82=D0=BD=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=C2=A77,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B0=20=C2=AB=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=20=3D=200=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удалён пропуск явных правил, когда в колонке нет членов правой части: спека statement-checks требует считать отсутствующий код нулём, при ненулевой цели — жёсткое нарушение. Тест плана test_per_column_and_null_as_zero противоречил спеке (1600 = 1700 без строки 1700 — нарушение) и заменён двумя тестами по спеке. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/statement_checks.py | 5 ----- api/tests/test_domain_statement_checks.py | 24 ++++++++++++++++------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index a580eb5..f758e5b 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -188,11 +188,6 @@ def run_checks( for rule in [*explicit, *sublines]: if rule not in sublines and rule.target not in amounts: continue - # Для явных правил (не подстроки): пропустить, если целевой есть, но ни одного члена нет - if rule not in sublines: - term_codes = {code for _, code in rule.terms} - if not any(code in amounts for code in term_codes): - continue holds, expected, actual = _holds(amounts, rule, rules.tolerance) if holds: continue diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 682cd24..e98a138 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -73,14 +73,24 @@ def test_sublines_soft() -> None: assert report.failed == () -def test_per_column_and_null_as_zero() -> None: - report = run_checks( - "BALANCE", - _lines(("1100", {0: "300", 1: None}), ("1200", {0: "600", 1: "5"}), ("1600", {0: "900", 1: "5"})), - [0, 1], - RULES, +def test_target_present_no_terms_hard_failure() -> None: + # Спека: отсутствующий код = 0; если цель присутствует но членов нет, это нарушение + # Правило 1600 = 1100 + 1200, в колонке только 1600=1000 → 1600 ≠ 0, жёсткое нарушение + report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) + assert any( + item.relation == "1600 = 1100 + 1200" and item.expected == D("1000") and item.actual == D("0") + for item in report.failed + ) + + +def test_target_without_self_sum_hard_failure() -> None: + # Спека: пропуск только если цель отсутствует; если цель есть, правило проверяется + # Правило 1600 = 1700, в колонке только 1600=1000 (нет 1300, 1400, 1500) → нарушение + report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES) + assert any( + item.relation == "1600 = 1700" and item.expected == D("1000") and item.actual == D("0") + for item in report.failed ) - assert report.failed == () @pytest.mark.parametrize( -- 2.47.2 From 3a72702c3d7b9507bb6fcb5f7d9d4586976942af Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:17:27 +0400 Subject: [PATCH 06/36] =?UTF-8?q?feat(statements):=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D0=B5=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D0=B4,=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81;=20counterparty?= =?UTF-8?q?=5Fcheck=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20(D-2,=20D-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/osv_mapping.py | 16 +- api/src/domain/statement_mapping.py | 225 +++++++++++++++++++++ api/src/services/result_mapping.py | 4 +- api/tests/test_domain_osv_mapping.py | 33 +-- api/tests/test_domain_statement_mapping.py | 155 ++++++++++++++ 5 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 api/src/domain/statement_mapping.py create mode 100644 api/tests/test_domain_statement_mapping.py diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 71b2597..f367562 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -14,7 +14,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from decimal import Decimal -from src.contract.result import OsvChecks, OsvDocument, OsvHeader, OsvRow +from src.contract.result import OsvChecks, OsvDocument, OsvRow from src.domain.aliases import normalize_alias from src.models.enums import ( CounterpartyMatchKind, @@ -206,25 +206,25 @@ def document_fields(doc: OsvDocument) -> DocumentDraft: def counterparty_check( - header: OsvHeader, owner_inn: str, owner_names_normalized: frozenset[str] + inn: str | None, name: str | None, owner_inn: str, owner_names_normalized: frozenset[str] ) -> DocumentCheckResult: - """D-8: организация в шапке ОСВ — владелец файла? + """D-8, Срез 2c/3b: организация в шапке — владелец файла? ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет → - `normalize_alias(organization_name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`. + `normalize_alias(name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`. Нет ни ИНН, ни имени (в том числе имя только из ОПФ и пунктуации) → `UNKNOWN`. """ - header_inn = _NON_DIGITS_RE.sub("", header.organization_inn or "") + header_inn = _NON_DIGITS_RE.sub("", inn or "") if header_inn: return ( DocumentCheckResult.MATCH if header_inn == _NON_DIGITS_RE.sub("", owner_inn) else DocumentCheckResult.MISMATCH ) - name = normalize_alias(header.organization_name) - if not name: + normalized = normalize_alias(name or "") + if not normalized: return DocumentCheckResult.UNKNOWN - return DocumentCheckResult.MATCH if name in owner_names_normalized else DocumentCheckResult.MISMATCH + return DocumentCheckResult.MATCH if normalized in owner_names_normalized else DocumentCheckResult.MISMATCH def _checks_clean(checks: OsvChecks | None) -> bool: diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py new file mode 100644 index 0000000..6a4bfdc --- /dev/null +++ b/api/src/domain/statement_mapping.py @@ -0,0 +1,225 @@ +"""Чистые функции маппинга формы ББ/ОФР (`statementDocument` v1.2) в черновики `financial_statement_*` +(spec osv-result-mapping «Документ формы ББ/ОФР», «Статус документа формы»; design D-2, D-3, D-6, D-14).""" + +import datetime +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from src.contract.result import StatementDocument, StatementValue +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType + +__all__ = [ + "LineDraft", + "StatementDraft", + "ValueDraft", + "build_statement", + "edition_for", + "quarter_of", + "statement_period", + "statement_status", + "values_ok", +] + +FULL_CONFIDENCE = Decimal("100") +EDITION_BOUNDARY = datetime.date(2024, 12, 31) +_QUARTER_ENDS = { + (3, 31): PeriodQuarter.Q1, + (6, 30): PeriodQuarter.Q2, + (9, 30): PeriodQuarter.Q3, + (12, 31): PeriodQuarter.Q4, +} +_STATEMENT_TYPES = {DocumentType.BALANCE: StatementType.balance, DocumentType.INCOME: StatementType.income} + + +@dataclass +class ValueDraft: + line_code: str + column_index: int + amount: Decimal | None + string_value: str | None + confidence: Decimal | None + is_recognized: bool | None + + +@dataclass +class LineDraft: + line_code: str + line_code_confidence: Decimal | None + line_name: str + line_name_confidence: Decimal | None + + +@dataclass +class StatementDraft: + document_type: DocumentType + statement_type: StatementType + locator: str + period_date: datetime.date | None + period_year: int | None + period_quarter: PeriodQuarter | None + header: dict[str, Any] + columns: list[tuple[int, str, datetime.date | None, bool | None]] + lines: list[LineDraft] + values: list[ValueDraft] + warnings: list[str] = field(default_factory=list) + + +def quarter_of(day: datetime.date) -> PeriodQuarter | None: + return _QUARTER_ENDS.get((day.month, day.day)) + + +def statement_period( + report_date: datetime.date | None, column_dates: Sequence[datetime.date | None] +) -> datetime.date | None: + """D-2: дата отчёта, иначе самая поздняя дата колонок.""" + if report_date is not None: + return report_date + dates = [day for day in column_dates if day is not None] + return max(dates) if dates else None + + +def edition_for(period_date: datetime.date | None) -> str | None: + """D-14: редакция формы для шаблона фронта.""" + if period_date is None: + return None + return "66n" if period_date <= EDITION_BOUNDARY else "fsbu4" + + +def _decimal(value: float | None) -> Decimal | None: + return Decimal(str(value)) if value is not None else None + + +def build_statement(doc: StatementDocument) -> StatementDraft: + """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение + на каждую ячейку.""" + document_type = DocumentType(doc.document_type) + header = doc.header + warnings: list[str] = [] + column_dates = [column.period_date for column in doc.columns] + period_date = statement_period(header.report_date, column_dates) + period_year = period_date.year if period_date is not None else None + period_quarter = quarter_of(period_date) if period_date is not None else None + if period_date is None: + warnings.append(f"{doc.locator}: период формы не определён (нет даты отчёта и дат колонок)") + elif period_quarter is None: + warnings.append(f"{doc.locator}: дата {period_date.isoformat()} не конец квартала — квартал не определён") + latest = max((day for day in column_dates if day is not None), default=None) + columns = [ + ( + column.column_index, + column.label, + column.period_date, + None + if latest is None + else column.period_date == latest + and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest), + ) + for column in doc.columns + ] + known_columns = {column.column_index for column in doc.columns} + lines: list[LineDraft] = [] + values: list[ValueDraft] = [] + seen: set[str] = set() + for line in doc.lines: + if line.line_code in seen: + warnings.append(f"{doc.locator}: код {line.line_code} повторяется — оставлена первая строка") + continue + seen.add(line.line_code) + lines.append( + LineDraft( + line.line_code, + _decimal(line.line_code_confidence), + line.line_name, + _decimal(line.line_name_confidence), + ) + ) + by_column: dict[int, StatementValue] = {} + for cell_value in line.values: + if cell_value.column_index not in known_columns: + warnings.append( + f"{doc.locator}: строка {line.line_code}: значение колонки {cell_value.column_index}" + " вне колонок формы пропущено" + ) + continue + by_column.setdefault(cell_value.column_index, cell_value) + for column in doc.columns: + value: StatementValue | None = by_column.get(column.column_index) + values.append( + ValueDraft( + line.line_code, + column.column_index, + value.amount if value is not None else None, + value.string_value if value is not None else None, + _decimal(value.confidence) if value is not None else None, + value.is_recognized if value is not None else None, + ) + ) + inn_confidence = _decimal(header.inn_confidence) + meta = { + "inn": header.inn, + "inn_original_value": header.inn, + "inn_confidence": inn_confidence, + "inn_verified": inn_confidence == FULL_CONFIDENCE if header.inn is not None else None, + "inn_modified": False, + "organization_name": header.organization_name, + "report_date": header.report_date, + "report_date_original_value": header.report_date_text, + "report_date_verified": header.report_date is not None, + "report_date_modified": False, + "units": header.units, + "units_original_value": header.units, + "units_verified": header.units is not None, + "units_modified": False, + "okud": header.okud, + "okpo": header.okpo, + "okopf": header.okopf, + "okfs": header.okfs, + "address": header.address, + } + return StatementDraft( + document_type=document_type, + statement_type=_STATEMENT_TYPES[document_type], + locator=doc.locator, + period_date=period_date, + period_year=period_year, + period_quarter=period_quarter, + header=meta, + columns=columns, + lines=lines, + values=values, + warnings=warnings, + ) + + +def values_ok(values: Iterable[tuple[bool, bool | None, Decimal | None]]) -> bool: + """D-6: каждое значение правлено аналитиком или распознано без сомнений (`is_recognized` не false, + уверенность 100/NULL).""" + return all( + modified or (recognized is not False and (confidence is None or confidence == FULL_CONFIDENCE)) + for modified, recognized, confidence in values + ) + + +def statement_status( # noqa: PLR0913 - условия D-6 перечислены явно + *, + job_done: bool, + checks_passed: bool, + counterparty: DocumentCheckResult, + period_known: bool, + values_ok: bool, + has_lines: bool, + warnings: Sequence[str], +) -> DocumentStatus: + """D-6: `RECOGNIZED` только при всех условиях; иначе `NEEDS_CHECK`.""" + recognized = ( + job_done + and checks_passed + and counterparty != DocumentCheckResult.MISMATCH + and period_known + and values_ok + and has_lines + and not warnings + ) + return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index de61027..80ca04c 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -386,7 +386,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай sheet_warnings.append( f"sheet={sheet.locator}: период {sheet.header.period_text}: нарастающая по данным Q1" ) - check = counterparty_check(sheet.header, owner.inn, owner_names) + check = counterparty_check( + sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names + ) document = Document( id=uuid.uuid4(), stored_file_id=file.id, diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 92339b5..0c9d372 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -14,6 +14,7 @@ import pytest from scripts.seed_lib.loaders import load_chart_of_accounts from src.contract.result import OsvChecks, OsvDocument, OsvHeader +from src.domain.aliases import normalize_alias from src.domain.osv_mapping import ( DocumentDraft, EntryDraft, @@ -244,21 +245,21 @@ def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None: @pytest.mark.parametrize( - ("header", "expected"), + ("inn", "name", "expected"), [ - ({"organization_name": "АО «Русский бисквит»", "organization_inn": OWNER_INN}, DocumentCheckResult.MATCH), - ({"organization_name": "", "organization_inn": " 3528 052718 "}, DocumentCheckResult.MATCH), - ({"organization_name": "АО «Русский бисквит»", "organization_inn": "3528000000"}, DocumentCheckResult.MISMATCH), - ({"organization_name": 'АО "Русский бисквит"'}, DocumentCheckResult.MATCH), # по нормализованному имени - ({"organization_name": "ООО РБ"}, DocumentCheckResult.MATCH), # по алиасу - ({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH), - ({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка - ({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN), - ({"organization_name": "ООО"}, DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя + (OWNER_INN, "АО «Русский бисквит»", DocumentCheckResult.MATCH), + (" 3528 052718 ", "", DocumentCheckResult.MATCH), + ("3528000000", "АО «Русский бисквит»", DocumentCheckResult.MISMATCH), + (None, 'АО "Русский бисквит"', DocumentCheckResult.MATCH), # по нормализованному имени + (None, "ООО РБ", DocumentCheckResult.MATCH), # по алиасу + (None, "Пекарня Счастливочная", DocumentCheckResult.MISMATCH), + (None, "", DocumentCheckResult.UNKNOWN), # пустая шапка + ("", " ", DocumentCheckResult.UNKNOWN), + (None, "ООО", DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя ], ) -def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None: - assert counterparty_check(_header(**header), OWNER_INN, OWNER_NAMES) is expected +def test_counterparty_check(inn: str | None, name: str | None, expected: DocumentCheckResult) -> None: + assert counterparty_check(inn, name, OWNER_INN, OWNER_NAMES) is expected # --- статус документа (§9.3, OQ-05) --------------------------------------------------------------------------------- @@ -319,3 +320,11 @@ def test_account_sort_key_survives_non_ascii_digits(code: str) -> None: key = account_sort_key(code) assert key == account_sort_key(code) # детерминирован assert sorted(["A", code, "60"], key=account_sort_key)[0] == "60" # ASCII-числа — раньше нечисловых сегментов + + +def test_counterparty_check_plain_values() -> None: + names = frozenset({normalize_alias("ООО Ромашка")}) + assert counterparty_check("7700000000", None, "7700000000", names) is DocumentCheckResult.MATCH + assert counterparty_check(None, "ООО «Ромашка»", "7700000001", names) is DocumentCheckResult.MATCH + assert counterparty_check("3121200351", "мусор", "7700000000", names) is DocumentCheckResult.MISMATCH + assert counterparty_check(None, None, "7700000000", names) is DocumentCheckResult.UNKNOWN diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py new file mode 100644 index 0000000..515ce1a --- /dev/null +++ b/api/tests/test_domain_statement_mapping.py @@ -0,0 +1,155 @@ +"""Чистые функции маппинга формы (spec osv-result-mapping «Документ формы ББ/ОФР»; design D-2, D-3, D-6, D-14).""" + +import datetime +from decimal import Decimal + +import pytest + +from src.contract.result import StatementDocument +from src.domain.statement_mapping import ( + build_statement, + edition_for, + quarter_of, + statement_period, + statement_status, + values_ok, +) +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType + +d = datetime.date + + +@pytest.mark.parametrize( + ("day", "quarter"), + [ + (d(2025, 3, 31), PeriodQuarter.Q1), + (d(2025, 6, 30), PeriodQuarter.Q2), + (d(2025, 9, 30), PeriodQuarter.Q3), + (d(2024, 12, 31), PeriodQuarter.Q4), + (d(2025, 10, 31), None), + (d(2025, 6, 29), None), + ], +) +def test_quarter_of(day: datetime.date, quarter: PeriodQuarter | None) -> None: + assert quarter_of(day) is quarter + + +def test_period_and_edition() -> None: + assert statement_period(d(2025, 9, 30), [d(2024, 12, 31)]) == d(2025, 9, 30) + assert statement_period(None, [d(2024, 12, 31), d(2025, 6, 30), None]) == d(2025, 6, 30) + assert statement_period(None, [None]) is None + assert edition_for(d(2024, 12, 31)) == "66n" + assert edition_for(d(2025, 3, 31)) == "fsbu4" + assert edition_for(None) is None + + +def _doc(**overrides: object) -> StatementDocument: + data: dict[str, object] = { + "document_type": "BALANCE", + "locator": "sheet:ББ", + "header": { + "form_name": "Бухгалтерский баланс", + "inn": "7700000000", + "inn_confidence": 100, + "report_date": "2025-09-30", + "report_date_text": "на 30 сентября 2025 г.", + "units": "тыс. руб.", + "organization_name": "ООО Пример", + "okud": "0710001", + }, + "columns": [ + {"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"}, + {"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}, + ], + "lines": [ + { + "line_code": "1150", + "line_code_confidence": 100, + "line_name": "ОС", + "line_name_confidence": 100, + "values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}], + }, + {"line_code": "1150", "line_name": "дубль", "values": []}, + { + "line_code": "1600", + "line_name": "БАЛАНС", + "values": [{"column_index": 5, "amount": "1", "confidence": 100, "is_recognized": True}], + }, + ], + } + data.update(overrides) + return StatementDocument.model_validate(data) + + +def test_build_statement_cells_and_header() -> None: + draft = build_statement(_doc()) + expected_cells = 4 # 2 строки × 2 колонки + assert draft.document_type is DocumentType.BALANCE and draft.statement_type is StatementType.balance + assert (draft.period_year, draft.period_quarter, draft.period_date) == (2025, PeriodQuarter.Q3, d(2025, 9, 30)) + assert draft.header["inn"] == "7700000000" and draft.header["inn_original_value"] == "7700000000" + assert draft.header["inn_verified"] is True + assert draft.header["report_date_original_value"] == "на 30 сентября 2025 г." + assert draft.header["units"] == draft.header["units_original_value"] == "тыс. руб." + assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert [line.line_code for line in draft.lines] == ["1150", "1600"] # дубль 1150 отброшен + cells = {(v.line_code, v.column_index): v for v in draft.values} + assert len(cells) == expected_cells + assert cells[("1150", 0)].amount == Decimal("10") and cells[("1150", 1)].amount is None + assert cells[("1150", 1)].is_recognized is None and cells[("1600", 0)].confidence is None + assert any("1150" in w and "повтор" in w for w in draft.warnings) + assert any("колонк" in w and "5" in w for w in draft.warnings) + + +def test_value_outside_columns_skipped() -> None: + """Review Focus 3: значение с column_index вне колонок — пропущено с предупреждением, остальное смаплено.""" + draft = build_statement(_doc()) + assert all(v.column_index in (0, 1) for v in draft.values) + + +def test_period_from_columns_and_non_quarter() -> None: + no_date = _doc(header={"form_name": "ББ", "report_date": None}) + draft = build_statement(no_date) + assert (draft.period_year, draft.period_quarter) == (2025, PeriodQuarter.Q3) + assert draft.header["report_date"] is None + october = _doc(header={"form_name": "ББ", "report_date": "2025-10-31"}) + draft = build_statement(october) + assert (draft.period_year, draft.period_quarter) == (2025, None) + assert any("конец квартала" in w for w in draft.warnings) + + +def test_no_dates_at_all() -> None: + draft = build_statement(_doc(header={"form_name": "ББ"}, columns=[{"column_index": 0, "label": ""}])) + assert (draft.period_year, draft.period_quarter, draft.period_date) == (None, None, None) + assert draft.columns == [(0, "", None, None)] + + +def test_status_rules() -> None: + ok = dict( + job_done=True, + checks_passed=True, + counterparty=DocumentCheckResult.MATCH, + period_known=True, + values_ok=True, + has_lines=True, + warnings=[], + ) + assert statement_status(**ok) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + for key, bad in [ + ("job_done", False), + ("checks_passed", False), + ("counterparty", DocumentCheckResult.MISMATCH), + ("period_known", False), + ("values_ok", False), + ("has_lines", False), + ("warnings", ["x"]), + ]: + assert statement_status(**{**ok, key: bad}) is DocumentStatus.NEEDS_CHECK # type: ignore[arg-type] + assert statement_status(**{**ok, "counterparty": DocumentCheckResult.UNKNOWN}) is DocumentStatus.RECOGNIZED # type: ignore[arg-type] + + +def test_values_ok() -> None: + hundred = Decimal("100") + assert values_ok([(False, True, hundred), (False, None, None)]) + assert not values_ok([(False, False, hundred)]) + assert not values_ok([(False, True, Decimal("99"))]) + assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз -- 2.47.2 From 29f516db3cc0d5c4eb9cccb346c297b33b5171c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:24:40 +0400 Subject: [PATCH 07/36] =?UTF-8?q?test(statements):=20=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D1=89=D0=BD=D0=B8=D0=BA=D0=B8=20golden=20=E2=80=94=20ocr?= =?UTF-8?q?=5Fresult,=20=D0=B2=D1=81=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=B2=20map=5Fgolden=5Fsnapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены: - Константы GOLDEN_STATEMENTS и OCR_EXPECTED в fixtures_helpers - Функция ocr_result для создания RecognitionResult из эталона нормализатора - Тест test_ocr_result_wraps_normalizer_golden, проверяющий порядок документов Изменены: - map_golden_snapshot теперь принимает параметр result_builder для переопределения функции создания RecognitionResult (по умолчанию golden_result) - Документы сортируются по (document_type, osv_account_code) вместо только osv_account_code Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/fixtures_helpers.py | 48 ++++++++++++++++++++++++++----- api/tests/test_fixtures_marker.py | 21 +++++++++++++- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 2987c65..62d0d1d 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -3,6 +3,7 @@ import json import os import uuid +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path from typing import TYPE_CHECKING @@ -16,6 +17,8 @@ if TYPE_CHECKING: from src.models.files import Document FIXTURES_ENV = "ARM_KA_FIXTURES" +GOLDEN_STATEMENTS = Path("golden") / "expected_statement_parse" +OCR_EXPECTED = Path("ocr") / "expected_normalize" def resolve_fixtures_dir() -> Path: @@ -40,6 +43,28 @@ def fixtures_skip_reason() -> str | None: ) +def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": + """Эталон нормализатора scan-gateway (`ocr/expected_normalize/.json`) + как `RecognitionResult` контракта v1.2. + """ + from src.contract.result import RecognitionResult # noqa: PLC0415 - цикл с conftest + + data = json.loads(path.read_text("utf-8")) + payload: dict[str, object] = { + "schema_version": "1.2", + "job_id": str(job_id), + "file_id": str(file_id), + "service": {"name": "scan-gateway", "version": "golden"}, + "status": data["status"], + "documents": data["documents"], + } + if data.get("error"): + payload["error"] = data["error"] + if data.get("metrics"): + payload["metrics"] = data["metrics"] + return RecognitionResult.model_validate(payload) + + def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": """Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса.""" from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest @@ -50,13 +75,19 @@ def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Reco async def map_golden_snapshot( - session: AsyncSession, *, entity: "LegalEntity", path: Path, uploaded_by: uuid.UUID | None + session: AsyncSession, + *, + entity: "LegalEntity", + path: Path, + uploaded_by: uuid.UUID | None, + result_builder: Callable[..., "RecognitionResult"] | None = None, ) -> "list[Document]": - """Golden-снимок `path` → файл ЮЛ `entity` и его документы ОСВ в БД теста (`flush`, без commit). + """Golden-снимок `path` → файл ЮЛ `entity` и его документы в БД теста (`flush`, без commit). - Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и настоящим - `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы до маппинга). - Возвращает документы файла (после автозамены прежних версий — только документы этого файла). + Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и + настоящим `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы + до маппинга). Возвращает документы файла отсортированные по (document_type, osv_account_code). + `result_builder` — функция создания RecognitionResult (по умолчанию golden_result). """ # ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл) from src.models.accounting import ChartOfAccount # noqa: PLC0415 @@ -78,9 +109,12 @@ async def map_golden_snapshot( code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) } index = await CounterpartyIndex.load(session) - result = golden_result(path, job_id=job.id, file_id=file.id) + builder = result_builder or golden_result + result = builder(path, job_id=job.id, file_id=file.id) await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC)) documents = await session.execute( - select(Document).where(Document.stored_file_id == file.id).order_by(Document.osv_account_code) + select(Document) + .where(Document.stored_file_id == file.id) + .order_by(Document.document_type, Document.osv_account_code) ) return list(documents.scalars()) diff --git a/api/tests/test_fixtures_marker.py b/api/tests/test_fixtures_marker.py index 6206e97..88b03a4 100644 --- a/api/tests/test_fixtures_marker.py +++ b/api/tests/test_fixtures_marker.py @@ -1,10 +1,17 @@ """Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»).""" +import uuid from pathlib import Path import pytest -from tests.fixtures_helpers import FIXTURES_ENV, fixtures_skip_reason, resolve_fixtures_dir +from tests.fixtures_helpers import ( + FIXTURES_ENV, + OCR_EXPECTED, + fixtures_skip_reason, + ocr_result, + resolve_fixtures_dir, +) @pytest.mark.fixtures @@ -34,3 +41,15 @@ def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tm assert reason is not None assert "ARM_KA_FIXTURES недоступен" in reason assert str(missing) in reason + + +@pytest.mark.fixtures +def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None: + """ocr_result читает эталон нормализатора и создаёт RecognitionResult + с правильным порядком документов. + """ + job_id, file_id = uuid.uuid4(), uuid.uuid4() + result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_6.json", job_id=job_id, file_id=file_id) + assert result.job_id == job_id and result.file_id == file_id + assert result.service.name == "scan-gateway" + assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"] -- 2.47.2 From 1d821b4d3e48b3d53d8ac0bc5631f6f002d5f270 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:40:59 +0400 Subject: [PATCH 08/36] =?UTF-8?q?feat(statements):=20=D0=BC=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D0=B3=20=D1=84=D0=BE=D1=80=D0=BC=20=D0=91?= =?UTF-8?q?=D0=91/=D0=9E=D0=A4=D0=A0=20=D0=B2=20map=5Ffile=20=E2=80=94=20m?= =?UTF-8?q?eta,=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=20(D-1=E2=80=A6D-3,=20D-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/statements.py | 140 ++++++++++++++++++ api/src/services/result_mapping.py | 129 +++++++++++++++-- api/tests/test_mapping_golden.py | 9 +- api/tests/test_mapping_statements.py | 204 +++++++++++++++++++++++++++ api/tests/test_result_mapping.py | 36 ++--- 5 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 api/src/repositories/statements.py create mode 100644 api/tests/test_mapping_statements.py diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py new file mode 100644 index 0000000..d3c95f3 --- /dev/null +++ b/api/src/repositories/statements.py @@ -0,0 +1,140 @@ +"""Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений. +Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`.""" + +import uuid +from decimal import Decimal +from typing import Any + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_mapping import StatementDraft +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + +__all__ = ["BATCH", "delete_file_statements", "insert_statement", "statement_amounts", "value_flags"] + +BATCH = 1000 # значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg + + +async def insert_statement( + session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft +) -> FinancialStatementMeta: + """Шапка, колонки, строки и значения формы из черновика (без commit); возвращает шапку.""" + meta = FinancialStatementMeta( + id=uuid.uuid4(), + document_id=document_id, + recognition_job_id=job_id, + statement_type=draft.statement_type, + **draft.header, + ) + session.add(meta) + await session.flush() + column_ids: dict[int, uuid.UUID] = {} + columns: list[dict[str, Any]] = [] + for column_index, label, period_date, is_current in draft.columns: + column_ids[column_index] = uuid.uuid4() + columns.append( + { + "id": column_ids[column_index], + "meta_id": meta.id, + "column_index": column_index, + "label": label, + "period_date": period_date, + "is_current": is_current, + } + ) + if columns: + await session.execute(insert(FinancialStatementColumn).values(columns)) + line_ids: dict[str, uuid.UUID] = {} + lines: list[dict[str, Any]] = [] + for line in draft.lines: + line_ids[line.line_code] = uuid.uuid4() + lines.append( + { + "id": line_ids[line.line_code], + "meta_id": meta.id, + "line_code": line.line_code, + "line_code_original_value": line.line_code, + "line_code_confidence": line.line_code_confidence, + "line_code_modified": False, + "line_name": line.line_name, + "line_name_original_value": line.line_name, + "line_name_confidence": line.line_name_confidence, + "line_name_modified": False, + } + ) + for start in range(0, len(lines), BATCH): + await session.execute(insert(FinancialStatementLine).values(lines[start : start + BATCH])) + values = [ + { + "id": uuid.uuid4(), + "line_id": line_ids[value.line_code], + "column_id": column_ids[value.column_index], + "amount": value.amount, + "string_value": value.string_value, + "confidence": value.confidence, + "is_recognized": value.is_recognized, + "is_modified": False, + } + for value in draft.values + ] + for start in range(0, len(values), BATCH): + await session.execute(insert(FinancialStatementValue).values(values[start : start + BATCH])) + return meta + + +async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: + """Значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" + documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() + lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() + await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(lines))) + await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementColumn).where(FinancialStatementColumn.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) + + +async def statement_amounts( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: + """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`.""" + columns = ( + await session.execute( + select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).all() + index_by_id = {column_id: index for column_id, index in columns} + rows = ( + await session.execute( + select(FinancialStatementLine.line_code, FinancialStatementValue.column_id, FinancialStatementValue.amount) + .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).all() + by_code: dict[str, dict[int, Decimal | None]] = {} + for code, column_id, amount in rows: + by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount + return [index for _, index in columns], list(by_code.items()) + + +async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]: + """`(is_modified, is_recognized, confidence)` всех значений формы — вход `values_ok`.""" + rows = await session.execute( + select( + FinancialStatementValue.is_modified, + FinancialStatementValue.is_recognized, + FinancialStatementValue.confidence, + ) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 80ca04c..972c554 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -11,10 +11,10 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn 3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, - документы и строки, `group_schema`, предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, - D-10, аудит `FILE_REPLACE`). + документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ), + предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`). -Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) +Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). `mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число @@ -37,7 +37,7 @@ from pydantic import ValidationError from sqlalchemy import Integer, Select, delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from src.contract.result import OsvDocument, RecognitionResult +from src.contract.result import OsvDocument, RecognitionResult, StatementDocument from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.osv_mapping import ( @@ -48,6 +48,8 @@ from src.domain.osv_mapping import ( document_status, opening_matches, ) +from src.domain.statement_checks import load_check_rules, run_checks +from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok from src.models.accounting import ChartOfAccount from src.models.directories import LegalEntity from src.models.enums import ( @@ -64,6 +66,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.repositories import statements as statements_repo from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService @@ -74,6 +77,7 @@ __all__ = [ "MAPPING_ATTEMPTS_KEY", "MAPPING_ERROR_KEY", "MAPPING_WARNINGS_KEY", + "NO_DOCUMENTS", "Candidate", "MappingFatal", "map_file", @@ -89,6 +93,7 @@ MAPPING_ERROR_KEY = "mapping_error" # причина `ERROR` файла по м MAPPING_WARNINGS_KEY = "mapping_warnings" # предупреждения маппинга файла, список строк (D-7, R-6) ENTRY_BATCH_SIZE = 1000 # строк `osv_entry` на один INSERT: 23 колонки × 1000 < 32767 параметров asyncpg MAPPING_ERROR_MAX_LENGTH = 2000 +NO_DOCUMENTS = "в результате нет документов" RESULTS_PREFIX = "results" MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check) CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) @@ -206,8 +211,8 @@ async def _load_result(storage: ObjectStorage, candidate: Candidate) -> Recognit raise MappingFatal( f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" ) - if not _osv_documents(result): - raise MappingFatal("в результате нет документов ОСВ") + if not result.documents: + raise MappingFatal(NO_DOCUMENTS) return result @@ -261,6 +266,7 @@ def _entry_values( async def _clear_file(session: AsyncSession, file_id: uuid.UUID) -> None: """D-3: строки и документы файла от прерванной попытки (файл в `PROCESSING` правок аналитика не содержит).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + await statements_repo.delete_file_statements(session, file_id) await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(documents))) await session.execute(delete(Document).where(Document.stored_file_id == file_id)) @@ -330,6 +336,98 @@ def _split_duplicates(sheets: Sequence[OsvDocument]) -> tuple[list[OsvDocument], return kept, by_locator, warnings +def _map_statement_docs( # noqa: PLR0913 - все входы именованные + statement_docs: Sequence[StatementDocument], + *, + file: StoredFile, + owner: LegalEntity, + owner_names: frozenset[str], + now: datetime, +) -> tuple[list[tuple[Document, StatementDraft]], list[Document], list[str], set[DocumentType]]: + """Документы форм ББ/ОФР и расшифровок (spec «Документ формы ББ/ОФР», «Повтор формы в файле и прочие документы»; + D-2, D-3). Возвращает формы (документ + черновик; статус уточняется после вставки значений), документы + `DISCLOSURE`/`OTHER` (по одному на тип, `locator` через запятую, `RECOGNIZED`), предупреждения и типы форм, + у которых был повтор в файле (R-1: первый документ такого типа — `NEEDS_CHECK`).""" + forms: list[tuple[Document, StatementDraft]] = [] + seen_forms: set[DocumentType] = set() + duplicated: set[DocumentType] = set() + other_locators: dict[DocumentType, list[str]] = {} + warnings: list[str] = [] + for doc in statement_docs: + doc_type = DocumentType(doc.document_type) + if doc_type in (DocumentType.DISCLOSURE, DocumentType.OTHER): + other_locators.setdefault(doc_type, []).append(doc.locator) + continue + if doc_type in seen_forms: + warnings.append(f"{doc.locator}: вторая форма {doc_type.value} в файле пропущена (OQ-77)") + duplicated.add(doc_type) + continue + seen_forms.add(doc_type) + draft = build_statement(doc) + warnings.extend(draft.warnings) + check = counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + osv_account_code=None, + period_year=draft.period_year, + period_quarter=draft.period_quarter, + is_cumulative=None, + reporting_date=draft.period_date, + locator=doc.locator, + status=DocumentStatus.NEEDS_CHECK, # уточняется после вставки значений (нужны проверки по суммам) + period_check=DocumentCheckResult.UNKNOWN, + counterparty_check=check, + created_at=now, + updated_at=now, + ) + forms.append((document, draft)) + others = [ + Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + locator=",".join(locators), + status=DocumentStatus.RECOGNIZED, + period_check=DocumentCheckResult.UNKNOWN, + created_at=now, + updated_at=now, + ) + for doc_type, locators in other_locators.items() + ] + return forms, others, warnings, duplicated + + +async def _insert_statements( + session: AsyncSession, + *, + job: RecognitionJob, + forms: Sequence[tuple[Document, StatementDraft]], + duplicated: set[DocumentType], + job_done: bool, +) -> None: + """Шапка, колонки, строки и значения каждой формы; статус документа по D-6 (акцепта при маппинге нет).""" + if not forms: + return + rules = load_check_rules() + for document, draft in forms: + meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft) + column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + document.status = statement_status( + job_done=job_done, + checks_passed=not report.failed, + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + values_ok=values_ok(await statements_repo.value_flags(session, meta.id)), + has_lines=bool(draft.lines), + warnings=[*draft.warnings, *(["повтор формы"] if document.document_type in duplicated else [])], + ) + + async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные session: AsyncSession, *, @@ -342,13 +440,15 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай ) -> list[str]: """Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла. - Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки - пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из + Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) и на каждую + форму ББ/ОФР (`_map_statement_docs`) → строки ОСВ пакетами по `ENTRY_BATCH_SIZE` → вставка форм и их статус → + `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из `PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit. """ + if not result.documents: + raise MappingFatal(NO_DOCUMENTS) sheets = _osv_documents(result) - if not sheets: - raise MappingFatal("в результате нет документов ОСВ") + statement_docs = [doc for doc in result.documents if isinstance(doc, StatementDocument)] owner = await session.get(LegalEntity, file.legal_entity_id) if owner is None: raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}") @@ -422,6 +522,13 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for entry in mapped.entries ) + forms, other_documents, form_warnings, duplicated = _map_statement_docs( + statement_docs, file=file, owner=owner, owner_names=owner_names, now=now + ) + warnings.extend(form_warnings) + documents.extend(other_documents) + documents.extend(document for document, _ in forms) + session.add_all(documents) await session.flush() # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк @@ -430,6 +537,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for start in range(0, len(entries), ENTRY_BATCH_SIZE): await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) + await _insert_statements(session, job=job, forms=forms, duplicated=duplicated, job_done=job_done) + job.group_schema = [_group_schema(sheet) for sheet in sheets] job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} await session.flush() diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 2c62240..1c833ad 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.contract.result import RecognitionResult from src.domain.osv_mapping import normalize_account_code from src.models.accounting import ChartOfAccount -from src.models.enums import FileStatus, RecognitionStatus +from src.models.enums import DocumentType, FileStatus, RecognitionStatus from src.models.files import Document, StoredFile from src.models.osv import OsvEntry from src.repositories.counterparties import CounterpartyIndex @@ -148,7 +148,12 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P documents = { document.locator: document for document in ( - await session.execute(select(Document).where(Document.stored_file_id == file_id)) + await session.execute( + # формы ББ/ОФР смешанных снимков (s12) маппятся с Среза 3b — здесь сверяются только листы ОСВ + select(Document).where( + Document.stored_file_id == file_id, Document.document_type == DocumentType.OSV + ) + ) ).scalars() } sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"] diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py new file mode 100644 index 0000000..54e5224 --- /dev/null +++ b/api/tests/test_mapping_statements.py @@ -0,0 +1,204 @@ +"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и +прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures.""" + +import json +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document, StoredFile +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from tests.factories import make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" +NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3" +SYNTH = GOLDEN_STATEMENTS / "synthetic" +MEZH_BALANCE_LINES = 37 +NPZ_BALANCE_LINES = 132 +NPZ_INCOME_LINES = 43 +DISCLOSURE_LOCATORS_COMMAS = 4 # пять `DISCLOSURE` снимка st06 → пять locator через запятую +MIN_EXCEL_SNAPSHOTS = 25 +MIN_MAPPED_SNAPSHOTS = 27 + + +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id) + return (await session.execute(stmt)).scalar_one() + + +async def _count(session: AsyncSession, model: type, meta_id: uuid.UUID) -> int: + if model is FinancialStatementValue: + stmt = ( + select(func.count()) + .select_from(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + else: + stmt = select(func.count()).select_from(model).where(model.meta_id == meta_id) # type: ignore[attr-defined] + return (await session.execute(stmt)).scalar_one() + + +async def _owner(session: AsyncSession, path: Path) -> LegalEntity: + """Владелец файла с ИНН шапки формы (MATCH), чтобы чистые формы могли быть RECOGNIZED.""" + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name="ООО Владелец", inn=inn) + + +async def test_file_with_forms_only(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма1_бух_09.json" + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert [d.document_type for d in documents] == [DocumentType.BALANCE] + balance = documents[0] + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q3) + assert balance.reporting_date is not None and balance.reporting_date.isoformat() == "2025-09-30" + assert balance.osv_account_code is None and balance.is_cumulative is None + meta = await _meta(seeded, balance) + lines = await _count(seeded, FinancialStatementLine, meta.id) + columns = await _count(seeded, FinancialStatementColumn, meta.id) + assert lines == MEZH_BALANCE_LINES + assert await _count(seeded, FinancialStatementValue, meta.id) == lines * columns + file = await seeded.get(StoredFile, balance.stored_file_id) + assert file is not None and file.status is FileStatus.PROCESSED + + +async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, path) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + assert income.counterparty_check is DocumentCheckResult.MATCH + assert income.status is DocumentStatus.RECOGNIZED + + +async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = next((fixtures_dir / NPZ).glob("*.json")) + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + by_type = {d.document_type: d for d in documents} + assert set(by_type) == {DocumentType.BALANCE, DocumentType.INCOME} + balance_meta = await _meta(seeded, by_type[DocumentType.BALANCE]) + income_meta = await _meta(seeded, by_type[DocumentType.INCOME]) + assert await _count(seeded, FinancialStatementLine, balance_meta.id) == NPZ_BALANCE_LINES + assert await _count(seeded, FinancialStatementLine, income_meta.id) == NPZ_INCOME_LINES + for document in documents: + assert (document.period_year, document.period_quarter) == (2025, PeriodQuarter.Q3) + + +async def test_disclosures_merged(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Синтетика") + path = fixtures_dir / SYNTH / "st06_header_variants.json" + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + types = [d.document_type for d in documents] + assert types.count(DocumentType.DISCLOSURE) == 1 and DocumentType.INCOME in types + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED + assert disclosure.locator.count(",") == DISCLOSURE_LOCATORS_COMMAS + + +async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Без кодов") + path = fixtures_dir / SYNTH / "st03_no_codes.json" + (balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert balance.status is DocumentStatus.NEEDS_CHECK + meta = await _meta(seeded, balance) + assert await _count(seeded, FinancialStatementLine, meta.id) == 0 + # в снимке st03_no_codes колонок нет (`columns = []`): только шапка (расхождение со spec «и колонками») + assert await _count(seeded, FinancialStatementColumn, meta.id) == 0 + + +async def test_mixed_book_osv_and_balance(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Смешанная") + path = fixtures_dir / SYNTH / "st04_mixed_book.json" + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE} + + +async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж") + documents = await map_golden_snapshot( + seeded, + entity=owner, + path=fixtures_dir / OCR_EXPECTED / "aep_6.json", + uploaded_by=None, + result_builder=ocr_result, + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q2) + meta = await _meta(seeded, balance) + assert meta.report_date is None and meta.inn is None + current = ( + await seeded.execute( + select(FinancialStatementColumn.period_date).where( + FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.is_current.is_(True) + ) + ) + ).scalar_one() + assert current is not None and current.isoformat() == "2025-06-30" + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_ocr_noisy_form_mismatch(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Другое", inn="7700000000") + documents = await map_golden_snapshot( + seeded, + entity=owner, + path=fixtures_dir / OCR_EXPECTED / "aep_3.json", + uploaded_by=None, + result_builder=ocr_result, + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert balance.counterparty_check is DocumentCheckResult.MISMATCH + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_all_statement_snapshots_map(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Все golden форм (Excel и OCR, кроме ответов-ошибок) маппятся без исключений; у каждой формы значения на все + ячейки.""" + owner = await make_legal_entity(seeded, name="ООО Все формы") + excel = sorted((fixtures_dir / GOLDEN_STATEMENTS).rglob("*.json")) + ocr = sorted((fixtures_dir / OCR_EXPECTED).glob("*.json")) + assert len(excel) >= MIN_EXCEL_SNAPSHOTS + mapped = 0 + for path in excel: + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + mapped += 1 + for path in ocr: + if json.loads(path.read_text("utf-8"))["status"] == "error": + continue + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None, result_builder=ocr_result) + mapped += 1 + assert mapped >= MIN_MAPPED_SNAPSHOTS + metas = (await seeded.execute(select(FinancialStatementMeta.id))).scalars().all() + for meta_id in metas: + lines = await _count(seeded, FinancialStatementLine, meta_id) + columns = await _count(seeded, FinancialStatementColumn, meta_id) + assert await _count(seeded, FinancialStatementValue, meta_id) == lines * columns + + +async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: + """R-1, spec «Повтор формы в файле»: вторая форма того же типа не создаётся, первая — `NEEDS_CHECK` даже чистая.""" + source = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, source) + data = json.loads(source.read_text("utf-8")) + data["documents"].append(data["documents"][0] | {"locator": "sheet:повтор"}) + path = tmp_path / "duplicate.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert [d.document_type for d in documents] == [DocumentType.INCOME] + assert documents[0].status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index dfa06b3..52fc7bc 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -25,6 +25,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract import ack_msg_id, result_msg_id +from src.contract.result import RecognitionResult from src.core.config import Settings, get_settings from src.models.access import AppUser from src.models.accounting import ChartOfAccount @@ -47,12 +48,14 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.repositories.counterparties import CounterpartyIndex from src.services import result_mapping as rm from src.services.recognition_exchange import expire_deadlines, handle_message from src.services.result_mapping import ( MAPPING_ATTEMPTS_KEY, MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY, + NO_DOCUMENTS, Candidate, MappingFatal, process_once, @@ -335,32 +338,31 @@ async def test_missing_result_object_is_error(committed_sessionmaker: async_sess assert "не найден" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] -@pytest.mark.parametrize( - ("documents", "status", "message"), - [ - ([], "needs_check", "схем"), # контракт требует documents ≥ 1 — результат не проходит схему - ( - [{"document_type": "BALANCE", "locator": "pages:1", "header": {}, "columns": [], "lines": []}], - "needs_check", - "нет документов ОСВ", - ), - ], -) -async def test_result_without_osv_documents_is_error( - committed_sessionmaker: async_sessionmaker[AsyncSession], documents: list[Any], status: str, message: str -): - """Review Focus 2: результат без документов ОСВ → файл `ERROR` «нет документов ОСВ», не исключение цикла.""" +async def test_result_with_empty_documents_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Результат без документов не проходит схему контракта (documents ≥ 1) → файл `ERROR`, не исключение цикла.""" maker, storage = committed_sessionmaker, FakeStorage() - result = _minimal() | {"status": status, "documents": documents} + result = _minimal() | {"status": "needs_check", "documents": []} file_id, job_id, _ = await _prepare(maker, storage, result, job_status=RecognitionStatus.needs_check) assert await process_once(maker, storage, _settings()) is True assert (await _file(maker, file_id)).status is FileStatus.ERROR - assert message in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + assert "схем" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] assert await _documents(maker, file_id) == [] +async def test_map_file_without_documents_is_fatal(seeded: AsyncSession): + """`map_file` на результате без документов (схема такого не пропускает — защита) → `NO_DOCUMENTS`.""" + entity = await make_legal_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done + ) + result = RecognitionResult.model_construct(documents=[]) + index = await CounterpartyIndex.load(seeded) + with pytest.raises(MappingFatal, match=NO_DOCUMENTS): + await rm.map_file(seeded, job=job, file=file, result=result, chart={}, index=index, now=datetime.now(UTC)) + + async def test_mapping_failure_retries_then_error( committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch ): -- 2.47.2 From 729d4cb0728a9010a289c16f0831b34c8b794bc1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:51:06 +0400 Subject: [PATCH 09/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9613=E2=80=9315=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=203b;=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=C2=BB=20?= =?UTF-8?q?(=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=B5=D1=81=D1=82=D1=8C=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 5 ++++- .../slice-3b-statements-api/specs/osv-result-mapping/spec.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 0ab5b7a..8c3274d 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -52,7 +52,7 @@ Goals: формы ББ/ОФР из обоих источников в БД; API типа формы на файл. Второй `BALANCE`/`INCOME` того же файла не создаётся: предупреждение `: вторая форма <тип> в файле пропущена (OQ-77)`, первый документ — `NEEDS_CHECK`. `DISCLOSURE` и `OTHER` — один документ на тип на файл: `locator` — `locator` всех таких документов результата через запятую, статус `RECOGNIZED`, данных нет (OQ-23). Документ -`BALANCE`/`INCOME` без строк (форма без колонки кодов, `st03`) создаётся с шапкой и колонками, статус `NEEDS_CHECK`. +`BALANCE`/`INCOME` без строк (форма без колонки кодов, `st03`) создаётся с шапкой и колонками (если они есть в результате), статус `NEEDS_CHECK`. ### D-4. Проверки §7 — перенос tabular-parser, словарь — копия `domain/statement_checks.py`: `run_checks(form, columns, amounts, rules) -> ChecksReport` — дословный по семантике @@ -172,3 +172,6 @@ seed-dev` с нуля. | 10 | 2026-10-07 | Нет теста идентичности словаря §7 с tabular-parser | урок #33: межрепозиторные тесты ломают main | копии разойдутся незаметно | | 11 | 2026-10-07 | R-1: признак «повтор формы в файле» при маппинге хранится множеством типов форм и передаётся в `statement_status` как предупреждение; первый документ типа — `NEEDS_CHECK` | поведение по спеке («Повтор формы в файле»); фрагмент плана с разбором строк предупреждений — иллюстрация | — | | 12 | 2026-10-07 | R-2: пересчёт статуса формы после правки и акцепта не учитывает предупреждения маппинга (`warnings = ()`) | предупреждения маппинга в БД хранятся только на уровне файла (`metrics.mapping_warnings`) без привязки к документу; хранить их отдельно — новое поле, DBML v5.17 | документ с предупреждением маппинга (повтор кода, значение вне колонок, пропущенная вторая форма) после правки аналитиком может стать `RECOGNIZED`; аналитик форму уже просмотрел | +| 13 | 2026-10-07 | R-3: тест плана `test_per_column_and_null_as_zero` заменён тестами «отсутствующий код = 0 → нарушение» | тест плана противоречил спеке statement-checks: при цели 1600 и отсутствии 1700 соотношение `1600 = 1700` нарушено | потеряно покрытие «по колонкам/None» (досыпается в тест-доработке) | +| 14 | 2026-10-07 | R-4: формулировка «форма без строк создаётся с шапкой и колонками» уточнена до «колонками, если они есть в результате» | golden `st03_no_codes` содержит `columns = []` и `lines = []`; код создаёт форму с тем, что есть | — | +| 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) | diff --git a/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md index c8b88d2..133535e 100644 --- a/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md +++ b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md @@ -76,7 +76,7 @@ LOCKED`), проверять, что `result_s3_object` относится к б Второй документ `BALANCE` или `INCOME` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ — `NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без данных (`locator` — все `locator` этого типа через запятую, статус `RECOGNIZED`). Форма `BALANCE`/`INCOME` без строк -SHALL создаваться с шапкой и колонками. +SHALL создаваться с шапкой и колонками (если колонки есть в результате). #### Scenario: Расшифровки - **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`) @@ -84,7 +84,7 @@ SHALL создаваться с шапкой и колонками. #### Scenario: Форма без колонки кодов - **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0) -- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой и колонками, строк нет +- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате) ### Requirement: Статус документа формы Документ `BALANCE`/`INCOME` SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет непройденных -- 2.47.2 From 2b5e70f7c36abd3ebe4258bc2d776b455747a697 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:54:38 +0400 Subject: [PATCH 10/36] =?UTF-8?q?feat(statements):=20GET=20/documents/{id}?= =?UTF-8?q?/statement=20=E2=80=94=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=C2=A77,=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20(D-5,=20D-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схемы формы, StatementService (видимость, 409 DOCUMENT_NOT_STATEMENT, проверки §7 и межформенная), репозиторий (get_meta, load_form, pair_amounts), маршрут GET с DOCUMENT_VIEW, тесты API и детерминированный тест межформенной проверки. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/files.py | 13 ++ api/src/api/endpoints/files/__init__.py | 3 +- api/src/api/endpoints/files/statements.py | 35 +++ api/src/repositories/statements.py | 123 ++++++++++- api/src/schemas/statements.py | 226 +++++++++++++++++++ api/src/services/statements.py | 69 ++++++ api/tests/test_api_statements.py | 251 ++++++++++++++++++++++ api/tests/test_openapi_routes.py | 2 + 8 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 api/src/api/endpoints/files/statements.py create mode 100644 api/src/schemas/statements.py create mode 100644 api/src/services/statements.py create mode 100644 api/tests/test_api_statements.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index 5ef5a49..c18f316 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -10,6 +10,7 @@ from src.api.dependencies.directories import SettingsDep, get_scope from src.domain.policy import VisibilityScope from src.services.documents import DocumentService from src.services.primary_files import PrimaryFilesService +from src.services.statements import StatementService def get_primary_files_service( @@ -40,3 +41,15 @@ def get_document_service( DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)] + + +def get_statement_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> StatementService: + """Сервис формы ББ/ОФР: чтение, проверки §7 (видимость — через ЮЛ-владельца).""" + return StatementService(session, audit, scope) + + +StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)] diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index b1b0f9e..d7f946e 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from src.api.endpoints.files import documents, read, upload +from src.api.endpoints.files import documents, read, statements, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) router.include_router(read.router) router.include_router(documents.router) +router.include_router(statements.router) diff --git a/api/src/api/endpoints/files/statements.py b/api/src/api/endpoints/files/statements.py new file mode 100644 index 0000000..b06b4ae --- /dev/null +++ b/api/src/api/endpoints/files/statements.py @@ -0,0 +1,35 @@ +"""`/documents/{document_id}/statement` — форма ББ/ОФР для экрана проверки (spec documents-api «Форма ББ/ОФР для +экрана проверки»; design D-5, D-14). + +Чтение — `DOCUMENT_VIEW`; право проверяется до видимости, документ ЮЛ вне видимости — тот же 404, что несуществующий. +""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import StatementServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.statements import StatementOut + +router = APIRouter(prefix="/documents") + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "`DOCUMENT_NOT_STATEMENT`"}, +} + + +@router.get( + "/{document_id}/statement", + response_model=StatementOut, + dependencies=VIEW, + responses=STATEMENT_ERRORS, + summary="Форма ББ/ОФР документа", +) +async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> StatementOut: + """Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная).""" + return await service.get(document_id) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index d3c95f3..1f6a9b2 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -1,6 +1,7 @@ """Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений. Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`.""" +import datetime import uuid from decimal import Decimal from typing import Any @@ -9,7 +10,8 @@ from sqlalchemy import delete, insert, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_mapping import StatementDraft -from src.models.files import Document +from src.models.enums import DocumentStatus, DocumentType, FileStatus +from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, @@ -17,7 +19,16 @@ from src.models.statements import ( FinancialStatementValue, ) -__all__ = ["BATCH", "delete_file_statements", "insert_statement", "statement_amounts", "value_flags"] +__all__ = [ + "BATCH", + "delete_file_statements", + "get_meta", + "insert_statement", + "load_form", + "pair_amounts", + "statement_amounts", + "value_flags", +] BATCH = 1000 # значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg @@ -138,3 +149,111 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b .where(FinancialStatementLine.meta_id == meta_id) ) return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] + + +async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: + """Шапка формы документа (1:1) или `None`.""" + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def load_form( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]: + """Колонки по `column_index` и строки по `line_code` с их значениями по `column_index`.""" + columns = list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).scalars() + ) + order = {column.id: column.column_index for column in columns} + lines = list( + ( + await session.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).scalars() + ) + values = ( + await session.execute( + select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_([line.id for line in lines])) + ) + ).scalars() + by_line: dict[uuid.UUID, list[FinancialStatementValue]] = {line.id: [] for line in lines} + for value in values: + by_line[value.line_id].append(value) + for items in by_line.values(): + items.sort(key=lambda value: order[value.column_id]) + return columns, [(line, by_line[line.id]) for line in lines] + + +async def _line_amount( + session: AsyncSession, + meta_id: uuid.UUID, + line_code: str, + *, + current: bool = False, + period_date: datetime.date | None = None, +) -> Decimal | None: + """Сумма строки `line_code` в колонке `is_current` (`current`) или с датой `period_date`; нет — `None`.""" + stmt = ( + select(FinancialStatementValue.amount) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) + .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.line_code == line_code) + .order_by(FinancialStatementColumn.column_index) + .limit(1) + ) + if current: + stmt = stmt.where(FinancialStatementColumn.is_current.is_(True)) + else: + stmt = stmt.where(FinancialStatementColumn.period_date == period_date) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None: + """Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой, + затем самая поздняя по `created_at`.""" + stmt = ( + select(Document) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id == document.legal_entity_id, + Document.period_year == document.period_year, + Document.period_quarter == document.period_quarter, + Document.document_type == pair_type, + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by((Document.status == DocumentStatus.CONFIRMED).desc(), Document.created_at.desc()) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def pair_amounts( + session: AsyncSession, document: Document +) -> tuple[tuple[Decimal | None, Decimal | None], Decimal | None]: + """Вход межформенной проверки (D-5): `(1370 на отчётную дату, 1370 на 31.12 прошлого года)` из ББ и `2400` ОФР + в колонке отчётного периода. Нет пары, шапки пары, строки или колонки — соответствующий `None`.""" + if document.period_year is None or document.period_quarter is None: + return (None, None), None + is_balance = document.document_type == DocumentType.BALANCE + pair = await _pair_document(session, document, DocumentType.INCOME if is_balance else DocumentType.BALANCE) + own_meta = await get_meta(session, document.id) + pair_meta = await get_meta(session, pair.id) if pair is not None else None + balance_meta, income_meta = (own_meta, pair_meta) if is_balance else (pair_meta, own_meta) + balance: tuple[Decimal | None, Decimal | None] = (None, None) + if balance_meta is not None: + year_start = datetime.date(document.period_year - 1, 12, 31) + balance = ( + await _line_amount(session, balance_meta.id, "1370", current=True), + await _line_amount(session, balance_meta.id, "1370", period_date=year_start), + ) + income = await _line_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None + return balance, income diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py new file mode 100644 index 0000000..3b3c465 --- /dev/null +++ b/api/src/schemas/statements.py @@ -0,0 +1,226 @@ +"""Схемы формы ББ/ОФР для экрана «Проверка распознавания» (spec documents-api «Форма ББ/ОФР для экрана проверки»; +design D-5, D-14). + +Суммы — `Decimal` (в JSON — строка, без `float`), как `EntryOut`. Проверки §7 в ответе — вычисленные на лету +(`checks.failed` / `soft_failed` / `cross_form`), акцепт — из шапки. +""" + +import datetime +import uuid +from collections.abc import Sequence +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from src.domain.statement_checks import ChecksReport, FailedCheck +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + + +class HeaderFieldOut(BaseModel): + """Поле шапки, доступное для правки: значение, распознанное исходное и признаки проверки.""" + + value: str | None + original_value: str | None + confidence: Decimal | None = None + verified: bool | None + modified: bool | None + + +class StatementHeaderOut(BaseModel): + inn: HeaderFieldOut + report_date: HeaderFieldOut + units: HeaderFieldOut + organization_name: str | None + okud: str | None + okpo: str | None + okopf: str | None + okfs: str | None + address: str | None + + +class ColumnOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + column_index: int + label: str | None + period_date: datetime.date | None + is_current: bool | None + + +class ValueOut(BaseModel): + id: uuid.UUID + column_id: uuid.UUID + column_index: int + amount: Decimal | None + string_value: str | None + confidence: Decimal | None + is_recognized: bool | None + is_modified: bool + original_amount: Decimal | None + modified_by: uuid.UUID | None + modified_at: datetime.datetime | None + + +class LineOut(BaseModel): + id: uuid.UUID + line_code: str | None + line_code_confidence: Decimal | None + line_name: str | None + line_name_confidence: Decimal | None + values: list[ValueOut] + + +class FailedCheckOut(BaseModel): + """Нарушенное соотношение §7; `column_index = -1` — межформенная проверка (колонки нет).""" + + model_config = ConfigDict(from_attributes=True) + + column_index: int + relation: str + expected: Decimal + actual: Decimal + + +class ChecksAcceptedOut(BaseModel): + by: uuid.UUID + at: datetime.datetime + comment: str + + +class ChecksOut(BaseModel): + failed: list[FailedCheckOut] + soft_failed: list[FailedCheckOut] + cross_form: FailedCheckOut | None + accepted: ChecksAcceptedOut | None + + +class StatementDocumentOut(BaseModel): + id: uuid.UUID + document_type: DocumentType + status: DocumentStatus + period_year: int | None + period_quarter: PeriodQuarter | None + reporting_date: datetime.date | None + counterparty_check: DocumentCheckResult | None + locator: str | None + file_id: uuid.UUID + + +class StatementOut(BaseModel): + document: StatementDocumentOut + edition: str | None + header: StatementHeaderOut + columns: list[ColumnOut] + lines: list[LineOut] + checks: ChecksOut + + +def _failed(check: FailedCheck) -> FailedCheckOut: + return FailedCheckOut( + column_index=check.column_index, relation=check.relation, expected=check.expected, actual=check.actual + ) + + +def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut: + return ValueOut( + id=value.id, + column_id=value.column_id, + column_index=column_index[value.column_id], + amount=value.amount, + string_value=value.string_value, + confidence=value.confidence, + is_recognized=value.is_recognized, + is_modified=value.is_modified, + original_amount=value.original_amount, + modified_by=value.modified_by, + modified_at=value.modified_at, + ) + + +def _accepted(meta: FinancialStatementMeta) -> ChecksAcceptedOut | None: + if meta.checks_accepted_at is None or meta.checks_accepted_by is None: + return None + return ChecksAcceptedOut( + by=meta.checks_accepted_by, at=meta.checks_accepted_at, comment=meta.checks_accepted_comment or "" + ) + + +def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из частей формы + document: Document, + meta: FinancialStatementMeta, + columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], + report: ChecksReport, + cross: FailedCheck | None, + *, + edition: str | None, +) -> StatementOut: + """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки.""" + column_index = {column.id: column.column_index for column in columns} + return StatementOut( + document=StatementDocumentOut( + id=document.id, + document_type=document.document_type, + status=document.status, + period_year=document.period_year, + period_quarter=document.period_quarter, + reporting_date=document.reporting_date, + counterparty_check=document.counterparty_check, + locator=document.locator, + file_id=document.stored_file_id, + ), + edition=edition, + header=StatementHeaderOut( + inn=HeaderFieldOut( + value=meta.inn, + original_value=meta.inn_original_value, + confidence=meta.inn_confidence, + verified=meta.inn_verified, + modified=meta.inn_modified, + ), + report_date=HeaderFieldOut( + value=meta.report_date.isoformat() if meta.report_date is not None else None, + original_value=meta.report_date_original_value, + verified=meta.report_date_verified, + modified=meta.report_date_modified, + ), + units=HeaderFieldOut( + value=meta.units, + original_value=meta.units_original_value, + verified=meta.units_verified, + modified=meta.units_modified, + ), + organization_name=meta.organization_name, + okud=meta.okud, + okpo=meta.okpo, + okopf=meta.okopf, + okfs=meta.okfs, + address=meta.address, + ), + columns=[ColumnOut.model_validate(column) for column in columns], + lines=[ + LineOut( + id=line.id, + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + line_name_confidence=line.line_name_confidence, + values=[_value_out(value, column_index) for value in values], + ) + for line, values in lines + ], + checks=ChecksOut( + failed=[_failed(check) for check in report.failed], + soft_failed=[_failed(check) for check in report.soft_failed], + cross_form=_failed(cross) if cross is not None else None, + accepted=_accepted(meta), + ), + ) diff --git a/api/src/services/statements.py b/api/src/services/statements.py new file mode 100644 index 0000000..4c122c6 --- /dev/null +++ b/api/src/services/statements.py @@ -0,0 +1,69 @@ +"""Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14). + +Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий. +Проверки §7 считаются на лету и не хранятся. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.policy import VisibilityScope +from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks +from src.domain.statement_mapping import edition_for +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.statements import FinancialStatementMeta +from src.repositories import files as files_repo +from src.repositories import statements as repo +from src.schemas import statements as schemas + +DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего +DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" +DOCUMENT_READONLY = "DOCUMENT_READONLY" +UNKNOWN_CELL = "UNKNOWN_CELL" +DUPLICATE_CELL = "DUPLICATE_CELL" +NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" +INVALID_INN = "INVALID_INN" +FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) + + +class StatementService: + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: + """Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма.""" + document = await files_repo.get_document(self._session, self._scope, document_id) + if document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + meta = await repo.get_meta(self._session, document.id) if document.document_type in FORM_TYPES else None + if meta is None: + raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") + return document, meta + + async def checks_report( + self, document: Document, meta: FinancialStatementMeta + ) -> tuple[ChecksReport, FailedCheck | None]: + """Проверки §7 по текущим суммам формы и мягкая межформенная проверка (D-5).""" + rules = load_check_rules() + column_indexes, amounts = await repo.statement_amounts(self._session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + balance_1370, income_2400 = await repo.pair_amounts(self._session, document) + return report, cross_form_check(balance_1370, income_2400, rules.tolerance) + + async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: + document, meta = await self._visible_statement(document_id) + return await self.build_out(document, meta) + + async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut: + """Ответ формы по текущему состоянию БД (используется и ответами правки/акцепта).""" + columns, lines = await repo.load_form(self._session, meta.id) + report, cross = await self.checks_report(document, meta) + return schemas.statement_out( + document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date) + ) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py new file mode 100644 index 0000000..3295a54 --- /dev/null +++ b/api/tests/test_api_statements.py @@ -0,0 +1,251 @@ +"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы», +«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" + +import datetime +import json +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path + +import pytest +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_checks import CROSS_FORM_RELATION +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentType, FileStatus, RecognitionStatus +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" # только DOCUMENT_VIEW +ROSTOV = "Ростовский региональный филиал" +MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" +MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" +MEZH_BALANCE_LINES = 37 + + +async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name=name, inn=inn, group=group) + + +async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document: + entity = await rostov_entity(session, fixtures_dir / path) + documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None) + return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)) + + +async def two_forms(session: AsyncSession, fixtures_dir: Path) -> tuple[Document, Document]: + """ББ и ОФР Межениновской за 2025 Q3 одного ЮЛ (два файла).""" + entity = await rostov_entity(session, fixtures_dir / MEZH_BALANCE) + balance = next( + d + for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + if d.document_type == DocumentType.BALANCE + ) + income = next( + d + for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) + if d.document_type == DocumentType.INCOME + ) + return balance, income + + +async def set_amount( # noqa: PLR0913 - адресация ячейки: документ, строка, колонка + session: AsyncSession, + document: Document, + line_code: str, + amount: Decimal, + *, + current: bool = False, + period_date: datetime.date | None = None, +) -> None: + """Прямая правка суммы ячейки в БД теста (колонка `is_current` или с датой `period_date`).""" + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + column_filter = ( + FinancialStatementColumn.is_current.is_(True) + if current + else FinancialStatementColumn.period_date == period_date + ) + column_id = ( + await session.execute( + select(FinancialStatementColumn.id) + .where(FinancialStatementColumn.meta_id == meta.id, column_filter) + .order_by(FinancialStatementColumn.column_index) + .limit(1) + ) + ).scalar_one() + line_id = ( + await session.execute( + select(FinancialStatementLine.id).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code + ) + ) + ).scalar_one() + await session.execute( + update(FinancialStatementValue) + .where(FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id) + .values(amount=amount) + ) + await session.flush() + + +async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["edition"] == "fsbu4" + assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" + assert len(body["lines"]) == MEZH_BALANCE_LINES + codes = [line["line_code"] for line in body["lines"]] + assert codes == sorted(codes) + columns = len(body["columns"]) + assert all(len(line["values"]) == columns for line in body["lines"]) + assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None + assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} + + +async def test_get_statement_shape_and_decimal_strings( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Документ, шапка и значения: поля спеки, суммы — строкой, `column_index` значения совпадает с колонкой.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(RKA) # DOCUMENT_VIEW достаточно + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert set(body["document"]) == { + "id", + "document_type", + "status", + "period_year", + "period_quarter", + "reporting_date", + "counterparty_check", + "locator", + "file_id", + } + assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"]) + assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"} + line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) + assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} + value = next(v for v in line["values"] if v["amount"] is not None) + assert isinstance(value["amount"], str) + assert set(value) == { + "id", + "column_id", + "column_index", + "amount", + "string_value", + "confidence", + "is_recognized", + "is_modified", + "original_amount", + "modified_by", + "modified_at", + } + index_by_id = {column["id"]: column["column_index"] for column in body["columns"]} + assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"]) + assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) + + +async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir, MEZH_INCOME) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert body["document"]["document_type"] == "INCOME" and body["edition"] == "fsbu4" + assert body["lines"] + + +async def test_statement_of_osv_is_conflict( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ") + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + osv = await make_document(seeded, file=file, job=job) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{osv.id}/statement") + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" + + +async def test_statement_hidden_is_404( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client("client_manager@armka.local") # другой филиал + assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND + + +async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + balance, _ = await two_forms(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{balance.id}/statement")).json() + cross = body["checks"]["cross_form"] + assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400" + + +async def test_cross_form_violation_is_reported_on_both_forms( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Детерминированно (D-5): 1370 = 500 на 30.09.2025 и 300 на 31.12.2024, 2400 = 150 → expected 200, actual 150.""" + balance, income = await two_forms(seeded, fixtures_dir) + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) + await set_amount(seeded, income, "2400", Decimal("150"), current=True) + client = await auth_client(KA_RF) + for document in (balance, income): + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert body["checks"]["cross_form"] == { + "column_index": -1, + "relation": CROSS_FORM_RELATION, + "expected": "200.00", + "actual": "150.00", + }, document.document_type + # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена + await set_amount(seeded, income, "2400", Decimal("200"), current=True) + body = (await client.get(f"/documents/{balance.id}/statement")).json() + assert body["checks"]["cross_form"] is None + + +async def test_cross_form_without_pair_is_null( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.get(f"/documents/{document.id}/statement")).json()["checks"]["cross_form"] is None + + +async def test_cross_form_ignores_superseded_pair( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Пара из файла `SUPERSEDED` не участвует (D-5).""" + balance, income = await two_forms(seeded, fixtures_dir) + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) + await set_amount(seeded, income, "2400", Decimal("150"), current=True) + from src.models.files import StoredFile # noqa: PLC0415 + + await seeded.execute( + update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED) + ) + await seeded.flush() + client = await auth_client(KA_RF) + assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index b55c4f4..1475f9c 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -51,6 +51,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, + # форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6) + "/api/v1/documents/{document_id}/statement": {"get"}, # consolidations-api (Срез 4) "/api/v1/consolidations": {"post"}, "/api/v1/company-groups/{group_id}/consolidations": {"get"}, -- 2.47.2 From 38a6a1b0c2989c670172956feb30f5c4f4ada640 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:09:43 +0400 Subject: [PATCH 11/36] =?UTF-8?q?feat(statements):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20(=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=98=D0=9D?= =?UTF-8?q?=D0=9D,=20=D0=B4=D0=B0=D1=82=D0=B0,=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B)=20=D0=B8=20=D0=B0=D0=BA=D1=86=D0=B5=D0=BF?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=E2=80=94=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82,=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B0=20(D-7,=20D-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /documents/{id}/statement и POST /documents/{id}/statement/checks/accept: блокировки «файл → документ», 409 FILE_SUPERSEDED/DOCUMENT_READONLY/DOCUMENT_NOT_STATEMENT, 422 INVALID_INN/UNKNOWN_CELL/DUPLICATE_CELL, один audit_event на запрос, сброс акцепта правкой, пересчёт статуса D-6. Замечания ревью Task 5: пара формы ищется только среди документов с шапкой формы (T5.1), строки формы упорядочены по (line_code, id) (T5.3), слабый тест межформенной проверки удалён (T5.4), локальный импорт в тестах убран (T5.6). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/files/statements.py | 48 +++- api/src/repositories/statements.py | 36 ++- api/src/schemas/statements.py | 35 ++- api/src/services/statements.py | 175 ++++++++++++++- api/tests/test_api_statements.py | 253 +++++++++++++++++++++- api/tests/test_openapi_routes.py | 3 +- 6 files changed, 527 insertions(+), 23 deletions(-) diff --git a/api/src/api/endpoints/files/statements.py b/api/src/api/endpoints/files/statements.py index b06b4ae..a3085b7 100644 --- a/api/src/api/endpoints/files/statements.py +++ b/api/src/api/endpoints/files/statements.py @@ -1,7 +1,8 @@ """`/documents/{document_id}/statement` — форма ББ/ОФР для экрана проверки (spec documents-api «Форма ББ/ОФР для -экрана проверки»; design D-5, D-14). +экрана проверки», «Правка формы», «Акцепт проверок формы»; design D-5, D-7, D-8, D-14). -Чтение — `DOCUMENT_VIEW`; право проверяется до видимости, документ ЮЛ вне видимости — тот же 404, что несуществующий. +Чтение — `DOCUMENT_VIEW`, правка и акцепт — `DOCUMENT_EDIT`; право проверяется до видимости, документ ЮЛ вне +видимости — тот же 404, что несуществующий. """ import uuid @@ -12,15 +13,28 @@ from fastapi import APIRouter, Depends, status from src.api.dependencies.auth import require from src.api.dependencies.files import StatementServiceDep from src.schemas.common import ErrorDetail -from src.schemas.statements import StatementOut +from src.schemas.statements import ChecksAcceptIn, StatementOut, StatementPatch router = APIRouter(prefix="/documents") VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = { status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "`DOCUMENT_NOT_STATEMENT`"}, } +EDIT_ERRORS: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "`FILE_SUPERSEDED`, `DOCUMENT_READONLY`, `DOCUMENT_NOT_CONFIRMABLE`, `DOCUMENT_NOT_STATEMENT`, " + "`NOTHING_TO_ACCEPT`", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": ErrorDetail, + "description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL` и ошибки схемы", + }, +} @router.get( @@ -33,3 +47,31 @@ STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = { async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> StatementOut: """Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная).""" return await service.get(document_id) + + +@router.patch( + "/{document_id}/statement", + response_model=StatementOut, + dependencies=EDIT, + responses=EDIT_ERRORS, + summary="Правка формы ББ/ОФР", +) +async def patch_statement(document_id: uuid.UUID, body: StatementPatch, service: StatementServiceDep) -> StatementOut: + """Значения и шапка (`inn`, `report_date`, `units`): аудит, сброс акцепта, пересчёт статуса. + + Запрос без изменений — 200 без аудита.""" + return await service.patch(document_id, body) + + +@router.post( + "/{document_id}/statement/checks/accept", + response_model=StatementOut, + dependencies=EDIT, + responses=EDIT_ERRORS, + summary="Акцепт непройденных проверок формы", +) +async def accept_statement_checks( + document_id: uuid.UUID, body: ChecksAcceptIn, service: StatementServiceDep +) -> StatementOut: + """Акцепт жёстких проверок §7 с комментарием; без нарушений — 409 `NOTHING_TO_ACCEPT`.""" + return await service.accept_checks(document_id, body) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 1f6a9b2..2001dca 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -3,6 +3,7 @@ import datetime import uuid +from collections.abc import Sequence from decimal import Decimal from typing import Any @@ -25,6 +26,7 @@ __all__ = [ "get_meta", "insert_statement", "load_form", + "lock_cells", "pair_amounts", "statement_amounts", "value_flags", @@ -151,6 +153,35 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] +async def lock_cells( + session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] +) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: + """Значения формы `meta_id` по `(line_id, column_id)` под `FOR UPDATE` с перечитыванием; чужие ячейки (строка не + этой формы или колонка не её) в словарь не попадают.""" + if not cells: + return {} + line_ids = {line_id for line_id, _ in cells} + stmt = ( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) + .where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementColumn.meta_id == meta_id, + FinancialStatementValue.line_id.in_(line_ids), + ) + .order_by(FinancialStatementValue.id) + .with_for_update(of=FinancialStatementValue) + .execution_options(populate_existing=True) + ) + wanted = set(cells) + return { + (value.line_id, value.column_id): value + for value in (await session.execute(stmt)).scalars() + if (value.line_id, value.column_id) in wanted + } + + async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: """Шапка формы документа (1:1) или `None`.""" stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) @@ -176,7 +207,7 @@ async def load_form( await session.execute( select(FinancialStatementLine) .where(FinancialStatementLine.meta_id == meta_id) - .order_by(FinancialStatementLine.line_code) + .order_by(FinancialStatementLine.line_code, FinancialStatementLine.id) ) ).scalars() ) @@ -219,10 +250,11 @@ async def _line_amount( async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None: """Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой, - затем самая поздняя по `created_at`.""" + затем самая поздняя по `created_at`. Документ без шапки формы не считается парой (иначе он затенял бы настоящую).""" stmt = ( select(Document) .join(StoredFile, StoredFile.id == Document.stored_file_id) + .join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id) .where( Document.legal_entity_id == document.legal_entity_id, Document.period_year == document.period_year, diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 3b3c465..e2d0a56 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -10,7 +10,7 @@ import uuid from collections.abc import Sequence from decimal import Decimal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from src.domain.statement_checks import ChecksReport, FailedCheck from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter @@ -224,3 +224,36 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч accepted=_accepted(meta), ), ) + + +class CellPatch(BaseModel): + """Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить.""" + + model_config = ConfigDict(extra="forbid") + + line_id: uuid.UUID + column_id: uuid.UUID + amount: Decimal | None = Field(max_digits=18, decimal_places=2) + + +class HeaderPatch(BaseModel): + """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`).""" + + model_config = ConfigDict(extra="forbid") + + inn: str | None = None + report_date: datetime.date | None = None + units: str | None = Field(None, min_length=1, max_length=50) + + +class StatementPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + + header: HeaderPatch | None = None + values: list[CellPatch] = Field(default_factory=list, max_length=5000) + + +class ChecksAcceptIn(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + comment: str = Field(min_length=1, max_length=2000) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 4c122c6..ef4defb 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -4,21 +4,36 @@ Проверки §7 считаются на лету и не хранятся. """ +import re import uuid +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit -from src.common.exceptions import ConflictError, NotFoundError +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.osv_mapping import counterparty_check from src.domain.policy import VisibilityScope from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks -from src.domain.statement_mapping import edition_for -from src.models.enums import DocumentType -from src.models.files import Document +from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok +from src.models.directories import LegalEntity +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentCheckResult, + DocumentStatus, + DocumentType, + FileStatus, + RecognitionStatus, +) +from src.models.files import Document, RecognitionJob from src.models.statements import FinancialStatementMeta from src.repositories import files as files_repo from src.repositories import statements as repo +from src.repositories.counterparties import CounterpartyIndex from src.schemas import statements as schemas +from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -27,6 +42,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" INVALID_INN = "INVALID_INN" +OBJECT_TYPE = "document" +INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) @@ -46,6 +63,24 @@ class StatementService: raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") return document, meta + async def _editable(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: + """Видимость → блокировки «файл → документ» → проверки D-7 (порядок 409 как у подтверждения).""" + visible, _ = await self._visible_statement(document_id) + file = await files_repo.lock_file(self._session, visible.stored_file_id) + document = await files_repo.lock_document(self._session, document_id) + if file is None or document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + if file.status is FileStatus.SUPERSEDED: + raise ConflictError(FILE_SUPERSEDED, "файл документа заменён новой версией") + if document.status is DocumentStatus.CONFIRMED: + raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл") + if document.status not in CONFIRMABLE_STATUSES: + raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится") + meta = await repo.get_meta(self._session, document.id) + if meta is None: + raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") + return document, meta + async def checks_report( self, document: Document, meta: FinancialStatementMeta ) -> tuple[ChecksReport, FailedCheck | None]: @@ -67,3 +102,135 @@ class StatementService: return schemas.statement_out( document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date) ) + + async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: + """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" + if meta.checks_accepted_at is not None: + return () + column_indexes, amounts = await repo.statement_amounts(self._session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules()) + return tuple(report.failed) + + async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: + """D-6 после правки или акцепта; `CONFIRMED` не трогает. + + Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`). + """ + if document.status is DocumentStatus.CONFIRMED: + return + job = await self._session.get(RecognitionJob, meta.recognition_job_id) + _, amounts = await repo.statement_amounts(self._session, meta.id) + document.status = statement_status( + job_done=job is not None and job.status is RecognitionStatus.done, + checks_passed=not await self.hard_failures(document, meta), + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + values_ok=values_ok(await repo.value_flags(self._session, meta.id)), + has_lines=bool(amounts), + warnings=(), + ) + document.updated_at = utc_now() + + async def _apply_cells( + self, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any] + ) -> None: + """Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`.""" + keys = [(cell.line_id, cell.column_id) for cell in body.values] + if len(keys) != len(set(keys)): + raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды") + cells = await repo.lock_cells(self._session, meta.id, keys) + if len(cells) != len(set(keys)): + raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме") + now = utc_now() + for cell in body.values: + value = cells[(cell.line_id, cell.column_id)] + if value.amount == cell.amount: + continue + key = f"value:{value.id}" + before[key], after[key] = value.amount, cell.amount + if not value.is_modified and value.original_amount is None: + value.original_amount = value.amount + value.amount = cell.amount + value.is_modified = value.amount != value.original_amount + value.modified_by, value.modified_at = self._audit.user_id, now + + async def _apply_header( + self, + document: Document, + meta: FinancialStatementMeta, + header: schemas.HeaderPatch, + before: dict[str, Any], + after: dict[str, Any], + ) -> None: + """Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода.""" + fields = header.model_fields_set + if "inn" in fields and header.inn != meta.inn: + before["inn"], after["inn"] = meta.inn, header.inn + meta.inn, meta.inn_modified = header.inn, True + owner = await self._session.get(LegalEntity, document.legal_entity_id) + if owner is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + index = await CounterpartyIndex.load(self._session) + document.counterparty_check = counterparty_check( + meta.inn, meta.organization_name, owner.inn, index.owner_names(owner) + ) + if "report_date" in fields and header.report_date != meta.report_date: + before["report_date"], after["report_date"] = meta.report_date, header.report_date + meta.report_date, meta.report_date_modified = header.report_date, True + columns, _ = await repo.load_form(self._session, meta.id) + period = statement_period(meta.report_date, [column.period_date for column in columns]) + document.reporting_date = period + document.period_year = period.year if period is not None else None + document.period_quarter = quarter_of(period) if period is not None else None + if "units" in fields and header.units is not None and header.units != meta.units: + before["units"], after["units"] = meta.units, header.units + meta.units, meta.units_modified = header.units, True + + async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut: + """Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса.""" + document, meta = await self._editable(document_id) + header = body.header + if header is not None and header.inn is not None and not INN_RE.fullmatch(header.inn): + raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") + before: dict[str, Any] = {} + after: dict[str, Any] = {} + await self._apply_cells(meta, body, before, after) + if header is not None: + await self._apply_header(document, meta, header, before, after) + if not after: + return await self.build_out(document, meta) + if meta.checks_accepted_at is not None: + before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at} + after["checks_accepted"] = None + meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None + await self._session.flush() + await self.recompute_status(document, meta) + await self._audit.record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type=OBJECT_TYPE, + object_id=document.id, + before_value=before, + after_value=after, + ) + return await self.build_out(document, meta) + + async def accept_checks(self, document_id: uuid.UUID, body: schemas.ChecksAcceptIn) -> schemas.StatementOut: + """Акцепт непройденных жёстких проверок (D-8): поля акцепта в шапке, пересчёт статуса, аудит.""" + document, meta = await self._editable(document_id) + if not await self.hard_failures(document, meta): + raise ConflictError(NOTHING_TO_ACCEPT, "непройденных жёстких проверок нет") + now = utc_now() + by = self._audit.user_id + meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment + await self._session.flush() + await self.recompute_status(document, meta) + await self._audit.record( + action=AuditAction.RECOGNIZED_DATA_EDIT, + section=AuditSection.RECOGNITION, + object_type=OBJECT_TYPE, + object_id=document.id, + before_value={"checks_accepted": None}, + after_value={"checks_accepted": {"by": by, "at": now, "comment": body.comment}}, + ) + return await self.build_out(document, meta) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 3295a54..80e9f20 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -3,6 +3,7 @@ import datetime import json +import uuid from decimal import Decimal from http import HTTPStatus from pathlib import Path @@ -12,9 +13,10 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION +from src.models.audit_ex import AuditEvent from src.models.directories import LegalEntity, RegionalOffice -from src.models.enums import DocumentType, FileStatus, RecognitionStatus -from src.models.files import Document +from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus +from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, @@ -194,14 +196,6 @@ async def test_statement_hidden_is_404( assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND -async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: - balance, _ = await two_forms(seeded, fixtures_dir) - client = await auth_client(KA_RF) - body = (await client.get(f"/documents/{balance.id}/statement")).json() - cross = body["checks"]["cross_form"] - assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400" - - async def test_cross_form_violation_is_reported_on_both_forms( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -241,11 +235,246 @@ async def test_cross_form_ignores_superseded_pair( await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) - from src.models.files import StoredFile # noqa: PLC0415 - await seeded.execute( update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED) ) await seeded.flush() client = await auth_client(KA_RF) assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None + + +async def test_cross_form_pair_without_meta_does_not_shadow( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T5.1: новее подтверждённый документ того же типа без шапки формы не мешает найти настоящую пару.""" + balance, income = await two_forms(seeded, fixtures_dir) + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) + await set_amount(seeded, income, "2400", Decimal("150"), current=True) + entity = await seeded.get(LegalEntity, balance.legal_entity_id) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + ghost = await make_document(seeded, file=file, job=job, year=2025, quarter=balance.period_quarter) + ghost.document_type = DocumentType.INCOME + ghost.osv_account_code = None + ghost.status = DocumentStatus.CONFIRMED + ghost.created_at = income.created_at + datetime.timedelta(days=1) + await seeded.flush() + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{balance.id}/statement")).json() + assert body["checks"]["cross_form"]["actual"] == "150.00" + + +async def _first_cell(client, document_id) -> tuple[dict, dict]: + body = (await client.get(f"/documents/{document_id}/statement")).json() + line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None) + return line, line["values"][0] + + +async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + original = cell["amount"] + new = str(Decimal(original) + 20) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] + assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original) + assert edited["is_modified"] is True and edited["modified_by"] is not None + event = ( + ( + await seeded.execute( + select(AuditEvent).where( + AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT + ) + ) + ) + .scalars() + .one() + ) + assert event.before_value and event.after_value + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]}, + ) + reverted = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] + assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original) + + +async def test_patch_report_date_recomputes_period( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) + body = response.json() + assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True + assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут + + +async def test_patch_inn_validates_and_rechecks( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}}) + assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY and bad.json()["detail"]["code"] == "INVALID_INN" + other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}}) + assert other.json()["document"]["counterparty_check"] == "MISMATCH" + assert other.json()["document"]["status"] == "NEEDS_CHECK" + + +async def test_patch_duplicate_cell_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 1.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"} + response = await client.patch( + f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]} + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "DUPLICATE_CELL" + + +async def test_patch_amount_precision_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + for amount in ("1.234", "12345678901234567"): + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, amount + _, after = await _first_cell(client, document.id) + assert after["amount"] == cell["amount"] and after["is_modified"] is False + + +async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + + +async def test_patch_cell_of_other_form_is_unknown( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Ячейка другой формы того же ЮЛ — не ячейка этого документа.""" + balance, income = await two_forms(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, income.id) + response = await client.patch( + f"/documents/{balance.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}]}, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "UNKNOWN_CELL" + + +async def test_patch_requires_edit_right( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(RKA) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.FORBIDDEN + + +async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == HTTPStatus.OK + events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all() + assert events == [] + + +async def _break_balance(client, document_id) -> None: + body = (await client.get(f"/documents/{document_id}/statement")).json() + line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600") + cell = line_1600["values"][0] + amount = str(Decimal(cell["amount"] or 0) + 1000) + await client.patch( + f"/documents/{document_id}/statement", + json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": amount}]}, + ) + + +async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + nothing = await client.post(f"{url}/checks/accept", json={"comment": "ок"}) + assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + await _break_balance(client, document.id) + broken = (await client.get(url)).json() + assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"]) + assert broken["document"]["status"] == "NEEDS_CHECK" + accepted = await client.post(f"{url}/checks/accept", json={"comment": "округление в форме клиента"}) + assert accepted.status_code == HTTPStatus.OK + assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента" + assert accepted.json()["document"]["status"] == "RECOGNIZED" # других причин NEEDS_CHECK нет (D-6) + await client.patch(url, json={"header": {"units": "руб."}}) + after = (await client.get(url)).json() + assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK" + + +async def test_accept_blank_comment_rejected( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": " "}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +async def test_patch_confirmed_readonly( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + url = f"/documents/{document.id}/statement" + response = await client.patch(url, json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY" + response = await client.post(f"{url}/checks/accept", json={"comment": "ок"}) + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY" + + +async def test_patch_superseded_file(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + await seeded.execute( + update(StoredFile).where(StoredFile.id == document.stored_file_id).values(status=FileStatus.SUPERSEDED) + ) + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "FILE_SUPERSEDED" + + +async def test_patch_osv_is_not_statement( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ 2") + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + osv = await make_document(seeded, file=file, job=job) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 1475f9c..a834587 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -52,7 +52,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, # форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6) - "/api/v1/documents/{document_id}/statement": {"get"}, + "/api/v1/documents/{document_id}/statement": {"get", "patch"}, + "/api/v1/documents/{document_id}/statement/checks/accept": {"post"}, # consolidations-api (Срез 4) "/api/v1/consolidations": {"post"}, "/api/v1/company-groups/{group_id}/consolidations": {"get"}, -- 2.47.2 From 6c00a8705f8076cc916849605f5b9c6a7867b57e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:23:03 +0400 Subject: [PATCH 12/36] =?UTF-8?q?fix(statements):=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BF=D0=BE=D0=B4=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=BE=D0=B9;=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=D0=B0=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=86=D0=B5=D0=BF=D1=82=D0=B0=20(I-1,=20T6.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: `_editable` брал шапку без перечитывания, и после ожидания блокировки видел устаревший акцепт и значения шапки. `get_meta(..., refresh=True)` читает с populate_existing после lock_file и lock_document. T6.1: тесты — содержимое аудита правки значения и акцепта, исходное значение даты отчёта, дата не на конце квартала, сброс акцепта правкой только значения, неизменность формы после INVALID_INN, свежесть шапки под блокировкой. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/statements.py | 8 +- api/src/services/statements.py | 4 +- api/tests/test_api_statements.py | 128 ++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 2001dca..ad303df 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -182,9 +182,13 @@ async def lock_cells( } -async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: - """Шапка формы документа (1:1) или `None`.""" +async def get_meta( + session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False +) -> FinancialStatementMeta | None: + """Шапка формы документа (1:1) или `None`; `refresh` — перечитать атрибуты, даже если объект уже в сессии.""" stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) + if refresh: + stmt = stmt.execution_options(populate_existing=True) return (await session.execute(stmt)).scalar_one_or_none() diff --git a/api/src/services/statements.py b/api/src/services/statements.py index ef4defb..37c59c3 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -76,7 +76,9 @@ class StatementService: raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл") if document.status not in CONFIRMABLE_STATUSES: raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится") - meta = await repo.get_meta(self._session, document.id) + # читаем под блокировкой: `_visible_statement` загрузил шапку до неё, а параллельный запрос мог её изменить + # (например, поставить акцепт) — без `refresh` в сессии остался бы устаревший экземпляр + meta = await repo.get_meta(self._session, document.id, refresh=True) if meta is None: raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") return document, meta diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 80e9f20..6a5c1de 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -13,6 +13,7 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION +from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus @@ -35,6 +36,7 @@ ROSTOV = "Ростовский региональный филиал" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_BALANCE_LINES = 37 +EVENTS_AFTER_BREAK_AND_ACCEPT = 2 async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: @@ -297,7 +299,11 @@ async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, .scalars() .one() ) - assert event.before_value and event.after_value + key = f"value:{edited['id']}" + assert set(event.before_value) == {key} == set(event.after_value) + assert Decimal(event.before_value[key]) == Decimal(original) and Decimal(event.after_value[key]) == Decimal(new) + user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + assert edited["modified_by"] == str(user_id) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]}, @@ -311,10 +317,26 @@ async def test_patch_report_date_recomputes_period( ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) - response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) + url = f"/documents/{document.id}/statement" + original = (await client.get(url)).json()["header"]["report_date"]["original_value"] + assert original is not None + response = await client.patch(url, json={"header": {"report_date": "2025-06-30"}}) body = response.json() assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True - assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут + assert body["header"]["report_date"]["value"] == "2025-06-30" + assert body["header"]["report_date"]["original_value"] == original # исходник не тронут + + +async def test_patch_report_date_not_quarter_end( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 5 (PATCH): дата не на конце квартала — квартал пуст, статус `NEEDS_CHECK`.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) + assert response.status_code == HTTPStatus.OK, response.text + body = response.json()["document"] + assert body["period_quarter"] is None and body["status"] == "NEEDS_CHECK" async def test_patch_inn_validates_and_rechecks( @@ -329,6 +351,61 @@ async def test_patch_inn_validates_and_rechecks( assert other.json()["document"]["status"] == "NEEDS_CHECK" +async def test_patch_invalid_inn_changes_nothing( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """После `INVALID_INN` значения из того же тела не применяются, аудита нет.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + line, cell = await _first_cell(client, document.id) + before = (await client.get(url)).json() + body = { + "header": {"inn": "12345"}, + "values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}], + } + response = await client.patch(url, json=body) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_INN" + assert (await client.get(url)).json() == before + events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all() + assert events == [] + + +async def test_patch_rereads_meta_under_lock( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Шапка перечитывается под блокировкой: акцепт, поставленный параллельным запросом после загрузки шапки в + сессию, виден правке и сбрасывается ею (иначе акцепт остался бы поверх изменённых данных).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + line, cell = await _first_cell(client, document.id) + # карта идентичности слабая: удерживаем шапку, как это делает запрос между `_visible_statement` и блокировкой + held = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + await seeded.execute( + update(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .values( + checks_accepted_by=user_id, + checks_accepted_at=datetime.datetime.now(datetime.UTC), + checks_accepted_comment="x", + ) + .execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим, как у ждущего запроса + ) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} + response = await client.patch(url, json={"values": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["checks"]["accepted"] is None + await seeded.refresh(held) + assert held.checks_accepted_at is None and held.checks_accepted_comment is None + event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one() + assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None + + async def test_patch_duplicate_cell_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -434,6 +511,51 @@ async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_c assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK" +async def test_accept_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + response = await client.post( + f"/documents/{document.id}/statement/checks/accept", json={"comment": " округление "} + ) + assert response.status_code == HTTPStatus.OK, response.text + user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + events = ( + ( + await seeded.execute( + select(AuditEvent).where( + AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT + ) + ) + ) + .scalars() + .all() + ) + assert len(events) == EVENTS_AFTER_BREAK_AND_ACCEPT # правка значения в `_break_balance` и акцепт + (accept,) = [event for event in events if "checks_accepted" in event.after_value] + assert accept.before_value == {"checks_accepted": None} + assert set(accept.after_value) == {"checks_accepted"} + assert set(accept.after_value["checks_accepted"]) == {"by", "at", "comment"} + assert accept.after_value["checks_accepted"]["by"] == str(user_id) + assert accept.after_value["checks_accepted"]["comment"] == "округление" # без пробелов по краям + + +async def test_value_patch_resets_accept( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сбрасывает акцепт и правка одного только значения (не только шапки).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + await _break_balance(client, document.id) + assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.OK + line, cell = await _first_cell(client, document.id) + item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} + response = await client.patch(url, json={"values": [item]}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["checks"]["accepted"] is None + + async def test_accept_blank_comment_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: -- 2.47.2 From 167969c7c971d98dc302f06880eac3dd688d1100 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:31:38 +0400 Subject: [PATCH 13/36] =?UTF-8?q?feat(statements):=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=E2=80=94=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=BE=D0=B4=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=C2=A77,=20reviewed=5Fby;=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=20=D0=B2=20=D1=87=D0=B5=D0=BA-=D0=BB=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=20=D0=B8=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B5=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20(D-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для BALANCE/INCOME подтверждение требует определённого периода (PERIOD_REQUIRED) и пройденных либо акцептованных жёстких проверок §7 (CHECKS_NOT_PASSED, нарушения в detail.failures); записывает financial_statement_meta.reviewed_by/at. Шапка читается под блокировкой документа с refresh, чтобы не использовать устаревший акцепт. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/files/documents.py | 3 +- api/src/services/documents.py | 48 ++++++++- api/tests/test_api_statements.py | 119 +++++++++++++++++++++- api/tests/test_consolidation_documents.py | 36 +++++++ 4 files changed, 199 insertions(+), 7 deletions(-) diff --git a/api/src/api/endpoints/files/documents.py b/api/src/api/endpoints/files/documents.py index 244d214..981de94 100644 --- a/api/src/api/endpoints/files/documents.py +++ b/api/src/api/endpoints/files/documents.py @@ -59,7 +59,8 @@ async def list_entries( **NOT_FOUND, status.HTTP_409_CONFLICT: { "model": ErrorDetail, - "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`", + "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР — " + "`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`)", }, }, summary="Подтвердить документ", diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 15fcfce..beedae9 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -23,7 +23,9 @@ from src.domain.policy import VisibilityScope from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.statements import FinancialStatementMeta from src.repositories import files as files_repo +from src.repositories import statements as statements_repo from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief from src.services import consolidation_documents @@ -35,6 +37,8 @@ WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предуп FILE_SUPERSEDED = "FILE_SUPERSEDED" ALREADY_CONFIRMED = "ALREADY_CONFIRMED" DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" +PERIOD_REQUIRED = "PERIOD_REQUIRED" +CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED" CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) @@ -146,13 +150,47 @@ class DocumentService: document = await self._visible(document_id) return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) + async def _check_statement(self, document: Document) -> FinancialStatementMeta | None: + """Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы. + + Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). + Для не-формы и документа без шапки — `None`, условий нет. + """ + from src.services.statements import FORM_TYPES, StatementService # noqa: PLC0415 - цикл импорта + + if document.document_type not in FORM_TYPES: + return None + meta = await statements_repo.get_meta(self._session, document.id, refresh=True) + if meta is None: + return None + if document.period_year is None or document.period_quarter is None: + raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта") + failures = await StatementService(self._session, self._audit, self._scope).hard_failures(document, meta) + if failures: + raise ConflictError( + CHECKS_NOT_PASSED, + "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", + failures=[ + { + "column_index": failure.column_index, + "relation": failure.relation, + "expected": str(failure.expected), + "actual": str(failure.actual), + } + for failure in failures + ], + ) + return meta + async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — - `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции - документ привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят - в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). + `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и + `CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и + перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям + «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` + (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) file = await files_repo.lock_file(self._session, visible.stored_file_id) @@ -170,12 +208,16 @@ class DocumentService: DOCUMENT_NOT_CONFIRMABLE, f"документ в статусе {document.status.value} подтвердить нельзя: нужен RECOGNIZED или NEEDS_CHECK", ) + meta = await self._check_statement(document) before = document.status now = utc_now() document.status = DocumentStatus.CONFIRMED document.confirmed_by = self._audit.user_id document.confirmed_at = now document.updated_at = now + if meta is not None: + meta.reviewed_by = self._audit.user_id + meta.reviewed_at = now await self._session.flush() await consolidation_documents.on_document_confirmed( self._session, self._audit.service, document=document, user_id=self._audit.user_id, now=now diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 6a5c1de..5a6d69e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -15,8 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent -from src.models.directories import LegalEntity, RegionalOffice -from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, RecognitionStatus from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, @@ -25,7 +25,13 @@ from src.models.statements import ( FinancialStatementValue, ) from tests.conftest import AuthClientFactory -from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file +from tests.factories import ( + make_company_group, + make_consolidation, + make_document, + make_legal_entity, + make_stored_file, +) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -600,3 +606,110 @@ async def test_patch_osv_is_not_statement( client = await auth_client(KA_RF) response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" + + +async def test_confirm_requires_checks( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + failures = blocked.json()["detail"]["failures"] + assert failures and set(failures[0]) == {"column_index", "relation", "expected", "actual"} + assert all(isinstance(failure["expected"], str) and isinstance(failure["actual"], str) for failure in failures) + status = await seeded.scalar(select(Document.status).where(Document.id == document.id)) + assert status is not DocumentStatus.CONFIRMED + await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"}) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED" + meta = ( + await seeded.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + assert meta.reviewed_by is not None and meta.reviewed_at is not None + + +async def test_confirm_after_non_quarter_date_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) + body = edit.json() + assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK" + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}}) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + + +async def test_period_required_precedes_checks_not_passed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Порядок D-9: при пустом периоде и нарушенной проверке — `PERIOD_REQUIRED`.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + + +async def test_confirm_form_rereads_accept_under_lock( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Акцепт, поставленный параллельным запросом после загрузки шапки в сессию, виден подтверждению (T6.I1).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + held = ( # карта идентичности слабая: удерживаем шапку без акцепта, как это делает ждущий запрос + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + assert held.checks_accepted_at is None + user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + await seeded.execute( + update(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .values( + checks_accepted_by=user_id, + checks_accepted_at=datetime.datetime.now(datetime.UTC), + checks_accepted_comment="x", + ) + .execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим + ) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + + +async def test_confirmed_form_in_checklist( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + entity = await seeded.get(LegalEntity, document.legal_entity_id) + assert entity is not None + group = await seeded.get(CompanyGroup, entity.company_group_id) + assert group is not None + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation( + seeded, group=group, year=2025, quarter=PeriodQuarter.Q3, members=[entity], initiator=user + ) + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE") + row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id)) + assert row["cells"][balance_index]["status"] == "CONFIRMED" + meta = ( + await seeded.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + assert meta.reviewed_by == user.id diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index cde20a7..d3c091c 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -11,6 +11,7 @@ import asyncio import datetime import uuid from collections.abc import Sequence +from pathlib import Path from typing import Any import pytest @@ -57,6 +58,7 @@ from tests.factories import ( make_related_group, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_OK = 200 HTTP_CREATED = 201 @@ -64,6 +66,8 @@ ROSTOV = "Ростовский региональный филиал" KA_RF = "ka_rf@armka.local" ACCOUNT = "60" YEAR = 2024 +FORM_YEAR = 2025 # период golden-формы `FORM_SNAPSHOT` +FORM_SNAPSHOT = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" LOCK_WAIT_SECONDS = 5 ROWS = [{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}] Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 @@ -1138,3 +1142,35 @@ async def test_on_documents_mapped_waits_for_transition_to_documents_processing( assert moved == [data["version_id"]] async with maker() as session: assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_mapped_statement_moves_version_without_binding(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Форма двигает версию: `BALANCE` Q3 в `NEEDS_CHECK` (ИНН шапки ≠ ИНН владельца) → `DATA_CORRECTION`.""" + group, entities = await _group(seeded) + user = await _user(seeded) + _, version = await make_consolidation( + seeded, + group=group, + year=FORM_YEAR, + quarter=Q3, + members=entities, + initiator=user, + status=ConsolidationStatus.DOCUMENTS_PROCESSING, + ) + documents = await map_golden_snapshot( + seeded, entity=entities[0], path=fixtures_dir / FORM_SNAPSHOT, uploaded_by=user.id + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert (balance.status, balance.period_year, balance.period_quarter) == (DocumentStatus.NEEDS_CHECK, FORM_YEAR, Q3) + file = await seeded.get(StoredFile, balance.stored_file_id) + assert file is not None + # `map_file` сам перехода не делает: он идёт после него, в `map_job_result` + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [version.id] + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + assert await _bound(seeded, version.id) == set() -- 2.47.2 From 3f744b7eb9425ad1525d2afacb69cbfdebe262b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:39:51 +0400 Subject: [PATCH 14/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9616=E2=80=9317=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=203b=20=E2=80=94=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B1=D0=B5=D0=B7=20meta=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8,=20detail.failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 4 +++- .../slice-3b-statements-api/specs/documents-api/spec.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 8c3274d..5f1d5a3 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -99,7 +99,7 @@ comment`, пересчитывает статус, `audit_event(RECOGNIZED_DATA_ ### D-9. Подтверждение формы `POST /documents/{id}/confirm` для `BALANCE`/`INCOME` дополнительно: непройденные неакцептованные жёсткие проверки — -409 `CHECKS_NOT_PASSED` (в `details` — список нарушений); `period_year` или `period_quarter` пуст — 409 +409 `CHECKS_NOT_PASSED` (в `detail.failures` — список нарушений); `period_year` или `period_quarter` пуст — 409 `PERIOD_REQUIRED`. Порядок проверок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`. Пишет `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и низкая уверенность подтверждение не блокируют (как у ОСВ). `DISCLOSURE`/`OTHER` — без условий. @@ -175,3 +175,5 @@ seed-dev` с нуля. | 13 | 2026-10-07 | R-3: тест плана `test_per_column_and_null_as_zero` заменён тестами «отсутствующий код = 0 → нарушение» | тест плана противоречил спеке statement-checks: при цели 1600 и отсутствии 1700 соотношение `1600 = 1700` нарушено | потеряно покрытие «по колонкам/None» (досыпается в тест-доработке) | | 14 | 2026-10-07 | R-4: формулировка «форма без строк создаётся с шапкой и колонками» уточнена до «колонками, если они есть в результате» | golden `st03_no_codes` содержит `columns = []` и `lines = []`; код создаёт форму с тем, что есть | — | | 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) | +| 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 | +| 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | diff --git a/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md index 947b2d1..4ccb557 100644 --- a/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md +++ b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md @@ -24,7 +24,7 @@ из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Для `BALANCE`/`INCOME` дополнительно: пустые `period_year` или `period_quarter` — 409 `PERIOD_REQUIRED`; непройденные неакцептованные жёсткие проверки §7 — 409 -`CHECKS_NOT_PASSED` со списком нарушений в `details`; при подтверждении SHALL записываться +`CHECKS_NOT_PASSED` со списком нарушений в `detail.failures`; при подтверждении SHALL записываться `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и уверенность значений подтверждение MUST NOT блокировать. Если совпали несколько условий, порядок: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`. Подтверждение берёт блокировки в порядке «файл → -- 2.47.2 From c9bd620a189df711c15212b4c1a98a7037e03387 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:46:43 +0400 Subject: [PATCH 15/36] =?UTF-8?q?feat(documents):=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=BA=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=83=20=D0=B8?= =?UTF-8?q?=20comments=5Fcount=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B5=20(D-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/GET /documents/{id}/comments, счётчик комментариев в карточке файла и документа одним запросом на пачку документов. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/files/__init__.py | 3 +- api/src/api/endpoints/files/comments.py | 46 +++++++++++++++++ api/src/repositories/files.py | 27 ++++++++++ api/src/schemas/comments.py | 32 ++++++++++++ api/src/schemas/documents.py | 1 + api/src/schemas/files.py | 1 + api/src/services/documents.py | 69 ++++++++++++++++++++++--- api/src/services/primary_files.py | 8 ++- api/tests/test_api_comments.py | 67 ++++++++++++++++++++++++ api/tests/test_api_documents.py | 1 + api/tests/test_api_files_read.py | 1 + api/tests/test_openapi_routes.py | 1 + 12 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 api/src/api/endpoints/files/comments.py create mode 100644 api/src/schemas/comments.py create mode 100644 api/tests/test_api_comments.py diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index d7f946e..711ad6a 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from src.api.endpoints.files import documents, read, statements, upload +from src.api.endpoints.files import comments, documents, read, statements, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) router.include_router(read.router) router.include_router(documents.router) +router.include_router(comments.router) router.include_router(statements.router) diff --git a/api/src/api/endpoints/files/comments.py b/api/src/api/endpoints/files/comments.py new file mode 100644 index 0000000..4eeecb3 --- /dev/null +++ b/api/src/api/endpoints/files/comments.py @@ -0,0 +1,46 @@ +"""`/documents/{document_id}/comments` — комментарии к документу (spec documents-api «Комментарии к документу»; D-11). + +Чтение — `DOCUMENT_VIEW`, добавление — `DOCUMENT_EDIT`; документ ЮЛ вне видимости — тот же 404, что несуществующий. +Правки и удаления комментариев нет; аудит комментариев не пишется (D-11). +""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import DocumentServiceDep +from src.schemas.comments import CommentIn, CommentOut +from src.schemas.common import ErrorDetail + +router = APIRouter(prefix="/documents") + +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{document_id}/comments", + response_model=list[CommentOut], + dependencies=[Depends(require("DOCUMENT_VIEW"))], + responses=NOT_FOUND, + summary="Комментарии к документу", +) +async def list_comments(document_id: uuid.UUID, service: DocumentServiceDep) -> list[CommentOut]: + """Комментарии по возрастанию `created_at` с ФИО автора.""" + return await service.comments(document_id) + + +@router.post( + "/{document_id}/comments", + response_model=CommentOut, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require("DOCUMENT_EDIT"))], + responses=NOT_FOUND, + summary="Добавить комментарий к документу", +) +async def add_comment(document_id: uuid.UUID, body: CommentIn, service: DocumentServiceDep) -> CommentOut: + """Комментарий к документу в любом статусе (в том числе файла `SUPERSEDED`); автор — пользователь запроса.""" + return await service.add_comment(document_id, body) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 07fd79c..3ece499 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -16,11 +16,15 @@ from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible +from src.models.access import AppUser +from src.models.audit_ex import Comment from src.models.directories import LegalEntity from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа + def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: """Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`).""" @@ -91,6 +95,29 @@ async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID] return grouped +async def comments_count_by_document(session: AsyncSession, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]: + """Число комментариев документов одним запросом: `document_id → count` (документы без комментариев отсутствуют).""" + if not document_ids: + return {} + stmt = ( + select(Comment.object_id, func.count(Comment.id)) + .where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id.in_(document_ids)) + .group_by(Comment.object_id) + ) + return {document_id: count for document_id, count in (await session.execute(stmt)).all()} + + +async def list_comments(session: AsyncSession, document_id: uuid.UUID) -> list[tuple[Comment, str]]: + """Комментарии документа по `created_at` (затем `id`) вместе с ФИО автора.""" + stmt = ( + select(Comment, AppUser.full_name) + .join(AppUser, AppUser.id == Comment.author_id) + .where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id == document_id) + .order_by(Comment.created_at, Comment.id) + ) + return [(comment, full_name) for comment, full_name in (await session.execute(stmt)).all()] + + async def get_document(session: AsyncSession, scope: VisibilityScope, document_id: uuid.UUID) -> Document | None: """Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости.""" stmt = _visible_documents(select(Document).where(Document.id == document_id), scope) diff --git a/api/src/schemas/comments.py b/api/src/schemas/comments.py new file mode 100644 index 0000000..84b967a --- /dev/null +++ b/api/src/schemas/comments.py @@ -0,0 +1,32 @@ +"""Схемы комментариев к документу (spec documents-api «Комментарии к документу»; design D-11).""" + +import datetime +import uuid + +from pydantic import BaseModel, ConfigDict, Field + +MAX_COMMENT_LENGTH = 4000 + + +class CommentIn(BaseModel): + """Тело комментария: после обрезки пробелов 1–4000 символов; пустой текст и слишком длинный — 422.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + text: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH) + + +class CommentAuthorOut(BaseModel): + """Автор комментария.""" + + id: uuid.UUID + full_name: str + + +class CommentOut(BaseModel): + """Комментарий: текст, автор и время создания.""" + + id: uuid.UUID + text: str + author: CommentAuthorOut + created_at: datetime.datetime diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py index 9090be8..2bbe179 100644 --- a/api/src/schemas/documents.py +++ b/api/src/schemas/documents.py @@ -53,6 +53,7 @@ class DocumentOut(BaseModel): confirmed_at: datetime.datetime | None group_schema: list[GroupOut] | None warnings: list[str] + comments_count: int = 0 # число комментариев документа (D-11) class EntryOut(BaseModel): diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 1eaab38..34a5a8e 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -76,6 +76,7 @@ class DocumentBrief(BaseModel): period_check: DocumentCheckResult | None counterparty_check: DocumentCheckResult | None locator: str | None + comments_count: int = 0 # число комментариев документа (D-11); в ORM-модели поля нет — задаётся при сборке class FileOut(BaseModel): diff --git a/api/src/services/documents.py b/api/src/services/documents.py index beedae9..38c4c37 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -11,7 +11,7 @@ """ import uuid -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -20,12 +20,15 @@ from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, NotFoundError from src.domain.policy import VisibilityScope +from src.models.access import AppUser +from src.models.audit_ex import Comment from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry from src.models.statements import FinancialStatementMeta from src.repositories import files as files_repo from src.repositories import statements as statements_repo +from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief from src.services import consolidation_documents @@ -67,8 +70,17 @@ def job_brief(job: RecognitionJob) -> JobBrief: ) -def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[Document]) -> FileOut: - """Карточка файла (spec documents-api «Файлы организации и карточка файла»).""" +def file_out( + file: StoredFile, + job: RecognitionJob | None, + documents: Sequence[Document], + counts: Mapping[uuid.UUID, int] | None = None, +) -> FileOut: + """Карточка файла (spec documents-api «Файлы организации и карточка файла»). + + `counts` — `document_id → число комментариев` (`files_repo.comments_count_by_document`); нет — нули. + """ + counts = counts or {} return FileOut( id=file.id, legal_entity_id=file.legal_entity_id, @@ -82,7 +94,10 @@ def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[D uploaded_by=file.uploaded_by, superseded_by_file_id=file.superseded_by_file_id, job=job_brief(job) if job is not None else None, - documents=[DocumentBrief.model_validate(document) for document in documents], + documents=[ + DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)}) + for document in documents + ], ) @@ -97,8 +112,9 @@ def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[Group return None -def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut: - """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга).""" +def document_out(document: Document, job: RecognitionJob | None, comments_count: int = 0) -> DocumentOut: + """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга), + `comments_count` — число комментариев документа.""" return DocumentOut( id=document.id, file_id=document.stored_file_id, @@ -117,6 +133,16 @@ def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut: group_schema=_group_schema(job, document.locator), # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), + comments_count=comments_count, + ) + + +def _comment_out(comment: Comment, full_name: str) -> CommentOut: + return CommentOut( + id=comment.id, + text=comment.text, + author=CommentAuthorOut(id=comment.author_id, full_name=full_name), + created_at=comment.created_at, ) @@ -139,11 +165,38 @@ class DocumentService: return None return await self._session.get(RecognitionJob, file.current_recognition_job_id) + async def _comments_count(self, document_id: uuid.UUID) -> int: + counts = await files_repo.comments_count_by_document(self._session, [document_id]) + return counts.get(document_id, 0) + + async def comments(self, document_id: uuid.UUID) -> list[CommentOut]: + """Комментарии видимого документа по `created_at` с ФИО автора; иначе 404.""" + document = await self._visible(document_id) + return [ + _comment_out(comment, full_name) + for comment, full_name in await files_repo.list_comments(self._session, document.id) + ] + + async def add_comment(self, document_id: uuid.UUID, body: CommentIn) -> CommentOut: + """Комментарий к видимому документу в любом статусе, в том числе файла `SUPERSEDED`; аудит не пишется (D-11).""" + document = await self._visible(document_id) + comment = Comment( + object_type=files_repo.COMMENT_OBJECT_TYPE, + object_id=document.id, + text=body.text, + author_id=self._audit.user_id, + created_at=utc_now(), + ) + self._session.add(comment) + await self._session.flush() + author = await self._session.get(AppUser, comment.author_id) + return _comment_out(comment, author.full_name if author is not None else "") + async def get(self, document_id: uuid.UUID) -> DocumentOut: """Карточка видимого документа; иначе 404.""" document = await self._visible(document_id) file = await self._session.get(StoredFile, document.stored_file_id) - return document_out(document, await self._current_job(file)) + return document_out(document, await self._current_job(file), await self._comments_count(document.id)) async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]: """Страница строк видимого документа по `row_number`; иначе 404.""" @@ -230,4 +283,4 @@ class DocumentService: before_value={"status": before.value}, after_value={"status": DocumentStatus.CONFIRMED.value}, ) - return document_out(document, await self._current_job(file)) + return document_out(document, await self._current_job(file), await self._comments_count(document.id)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index cb285dd..5efd649 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -262,7 +262,8 @@ class PrimaryFilesService: "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется", ) current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) - before = file_out(file, current, documents).model_dump(mode="json") + counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents]) + before = file_out(file, current, documents, counts).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла @@ -282,7 +283,10 @@ class PrimaryFilesService: async def _cards(self, files: list[StoredFile]) -> list[FileOut]: jobs = await files_repo.current_jobs(self._session, files) documents = await files_repo.documents_by_file(self._session, [file.id for file in files]) - return [file_out(file, jobs.get(file.id), documents.get(file.id, [])) for file in files] + counts = await files_repo.comments_count_by_document( + self._session, [document.id for group in documents.values() for document in group] + ) + return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts) for file in files] async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None: """Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому».""" diff --git a/api/tests/test_api_comments.py b/api/tests/test_api_comments.py new file mode 100644 index 0000000..7bece3d --- /dev/null +++ b/api/tests/test_api_comments.py @@ -0,0 +1,67 @@ +"""Комментарии к документу (spec documents-api «Комментарии к документу», «Файлы организации…»; D-11).""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import DocumentStatus, FileStatus, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import make_document, make_stored_file +from tests.test_api_documents import KA_RF, RKA, _rostov_entity + +pytestmark = pytest.mark.integration + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +COMMENTS_TOTAL = 2 + + +async def _doc(session: AsyncSession, status: DocumentStatus = DocumentStatus.CONFIRMED): + entity = await _rostov_entity(session, name="ООО Комментарии") + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + return file, await make_document(session, file=file, job=job, status=status) + + +async def test_add_and_list(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + file, document = await _doc(seeded) + client = await auth_client(KA_RF) + created = await client.post(f"/documents/{document.id}/comments", json={"text": " нет подписи руководителя "}) + assert created.status_code == HTTP_CREATED, created.text + assert created.json()["text"] == "нет подписи руководителя" + assert created.json()["author"]["full_name"] + await client.post(f"/documents/{document.id}/comments", json={"text": "второй"}) + listed = (await client.get(f"/documents/{document.id}/comments")).json() + assert [item["text"] for item in listed] == ["нет подписи руководителя", "второй"] + card = (await client.get(f"/files/{file.id}")).json() + assert card["documents"][0]["comments_count"] == COMMENTS_TOTAL + assert (await client.get(f"/documents/{document.id}")).json()["comments_count"] == COMMENTS_TOTAL + + +async def test_validation_and_rights(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, document = await _doc(seeded) + url = f"/documents/{document.id}/comments" + client = await auth_client(KA_RF) + assert (await client.post(url, json={"text": " "})).status_code == HTTP_UNPROCESSABLE + assert (await client.post(url, json={"text": "x" * 4001})).status_code == HTTP_UNPROCESSABLE + reader = await auth_client(RKA) + assert (await reader.post(url, json={"text": "можно?"})).status_code == HTTP_FORBIDDEN + assert (await reader.get(url)).status_code == HTTP_OK + stranger = await auth_client("client_manager@armka.local") + assert (await stranger.get(url)).status_code == HTTP_NOT_FOUND + + +async def test_comment_on_superseded_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded, name="ООО Заменённый") + file, job = await make_stored_file( + session=seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.RECOGNIZED) + client = await auth_client(KA_RF) + created = await client.post(f"/documents/{document.id}/comments", json={"text": "архивная версия"}) + assert created.status_code == HTTP_CREATED, created.text + listing = (await client.get(f"/legal-entities/{entity.id}/files", params={"include_superseded": "true"})).json() + assert listing["items"][0]["documents"][0]["comments_count"] == 1 diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 722391e..8eb6ca3 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -139,6 +139,7 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor ], # R-6: предупреждения маппинга файла целиком, без фильтра по листу "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], + "comments_count": 0, } diff --git a/api/tests/test_api_files_read.py b/api/tests/test_api_files_read.py index 2307188..c70d771 100644 --- a/api/tests/test_api_files_read.py +++ b/api/tests/test_api_files_read.py @@ -142,6 +142,7 @@ async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth "period_check": None, "counterparty_check": None, "locator": "sheet:Лист_1", + "comments_count": 0, } ] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index a834587..75738b9 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -51,6 +51,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, + "/api/v1/documents/{document_id}/comments": {"get", "post"}, # форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6) "/api/v1/documents/{document_id}/statement": {"get", "patch"}, "/api/v1/documents/{document_id}/statement/checks/accept": {"post"}, -- 2.47.2 From 68b8a3d2f7bc7b3a47942dbceaa46680f4383f8c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:51:43 +0400 Subject: [PATCH 16/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=E2=84=9618=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=203b=20=E2=80=94=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 5f1d5a3..9413393 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -177,3 +177,4 @@ seed-dev` с нуля. | 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) | | 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 | | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | +| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей | -- 2.47.2 From 310e63224dcd4b3c913a2b4e59e5d6c4d1f732dd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:55:00 +0400 Subject: [PATCH 17/36] =?UTF-8?q?feat(files):=20GET=20/files/{id}/content?= =?UTF-8?q?=20=E2=80=94=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B8=D0=B7=20S3,=20RFC=205987=20(D-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поток из S3 порциями без чтения в память; нет объекта — 404 FILE_CONTENT_NOT_FOUND до заголовков; при обрыве потребителя ответ S3 закрывается и отпускается. Content-Disposition вынесен в common/filenames. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 1 + .../api/endpoints/consolidations/export.py | 14 +- api/src/api/endpoints/files/read.py | 36 +++- api/src/common/filenames.py | 8 + api/src/core/clients.py | 16 ++ api/src/core/storage.py | 9 +- api/src/services/primary_files.py | 56 +++++- api/tests/conftest.py | 10 +- api/tests/test_api_files_content.py | 167 ++++++++++++++++++ api/tests/test_clients.py | 32 ++++ api/tests/test_openapi_routes.py | 1 + 11 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 api/tests/test_api_files_content.py diff --git a/README.md b/README.md index 7a85e03..d18f7cb 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,7 @@ email без учёта регистра проверяет только сер | `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией | | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | | `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | +| `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | diff --git a/api/src/api/endpoints/consolidations/export.py b/api/src/api/endpoints/consolidations/export.py index a960f42..8d348de 100644 --- a/api/src/api/endpoints/consolidations/export.py +++ b/api/src/api/endpoints/consolidations/export.py @@ -5,19 +5,20 @@ import uuid from typing import Annotated, Any -from urllib.parse import quote from fastapi import APIRouter, Depends, Query, Response, status from src.api.dependencies.audit import RequestAudit, get_request_audit from src.api.dependencies.auth import require from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep +from src.common.filenames import content_disposition from src.schemas.common import ErrorDetail from src.schemas.consolidations import OsvFilters router = APIRouter(prefix="/consolidations/versions") XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +EXPORT_ASCII_NAME = "svod-osv.xlsx" VIEW = [Depends(require("CONSOLIDATION_VIEW"))] RESPONSES: dict[int | str, dict[str, Any]] = { status.HTTP_200_OK: { @@ -28,12 +29,6 @@ RESPONSES: dict[int | str, dict[str, Any]] = { } -def content_disposition(filename: str) -> str: - """Заголовок вложения: ASCII-имя по умолчанию и `filename*` (RFC 5987) с полным percent-кодированием имени — - пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, заголовок остаётся чистым ASCII.""" - return f"attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''{quote(filename, safe='')}" - - @router.get( "/{version_id}/osv/export", response_class=Response, @@ -50,6 +45,5 @@ async def export_osv( """Файл `svod-osv_<ГК>_<год>-<квартал>_v.xlsx`: строки свода после фильтров (как у `GET …/osv`, без пагинации, в том же порядке) и лист пробелов по своду целиком. Пишет `audit_event(EXPORT)`.""" content, filename = await service.export(version_id, filters, audit) - return Response( - content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": content_disposition(filename)} - ) + header = content_disposition(filename, ascii_fallback=EXPORT_ASCII_NAME) + return Response(content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": header}) diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 3a1eaa4..a2b1ba4 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -1,6 +1,7 @@ """`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла (spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла -(spec primary-files «Удаление файла»; design D-10). +(spec primary-files «Удаление файла»; design D-10); `GET /files/{file_id}/content` — исходный файл потоком из S3 +(spec documents-api «Исходный файл»; design D-10). Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий. @@ -8,9 +9,10 @@ """ import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, Query, status +from fastapi.responses import StreamingResponse from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep @@ -66,6 +68,36 @@ async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileO return await service.get_file(file_id) +CONTENT_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: { + "description": "содержимое исходного файла (тип — по `file_type`)", + "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}, + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorDetail, + "description": "файл нет или вне видимости; FILE_CONTENT_NOT_FOUND — объекта нет в хранилище", + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "STORAGE_UNAVAILABLE: S3 не подключено"}, +} + + +@router.get( + "/files/{file_id}/content", + response_class=StreamingResponse, + dependencies=VIEW, + responses=CONTENT_RESPONSES, + summary="Исходный файл", +) +async def get_file_content( + file_id: uuid.UUID, + service: PrimaryFilesServiceDep, + disposition: Annotated[Literal["attachment", "inline"], Query()] = "attachment", +) -> StreamingResponse: + """Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере.""" + stream, media_type, header = await service.content(file_id, disposition) + return StreamingResponse(stream, media_type=media_type, headers={"Content-Disposition": header}) + + @router.delete( "/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/api/src/common/filenames.py b/api/src/common/filenames.py index 7f53139..c549cbc 100644 --- a/api/src/common/filenames.py +++ b/api/src/common/filenames.py @@ -6,6 +6,7 @@ import unicodedata from pathlib import PurePosixPath +from urllib.parse import quote from fastapi import HTTPException, UploadFile, status @@ -50,6 +51,13 @@ def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> s return name +def content_disposition(filename: str, *, disposition: str = "attachment", ascii_fallback: str) -> str: + """Заголовок `Content-Disposition`: ASCII-имя `ascii_fallback` и `filename*` (RFC 5987) с полным + percent-кодированием `filename` — пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, + заголовок остаётся чистым ASCII.""" + return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename, safe='')}" + + def extension(name: str) -> str: """Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка).""" return PurePosixPath(name).suffix.lstrip(".").lower() diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 36dd199..0d2860f 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -2,6 +2,7 @@ по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). """ +from collections.abc import AsyncIterator from io import BytesIO from miniopy_async import Minio @@ -45,3 +46,18 @@ class S3Storage: finally: response.close() await response.release() + + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + """Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве.""" + try: + response = await self.client.get_object(self.bucket, obj_name) + except S3Error as error: + if error.code == NO_SUCH_KEY: + raise ObjectNotFoundError(obj_name) from error + raise + try: + async for chunk in response.content.iter_chunked(chunk_size): + yield chunk + finally: + response.close() + await response.release() diff --git a/api/src/core/storage.py b/api/src/core/storage.py index 5ef1544..cdfb87a 100644 --- a/api/src/core/storage.py +++ b/api/src/core/storage.py @@ -4,6 +4,7 @@ `services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12). """ +from collections.abc import AsyncIterator from typing import Protocol @@ -12,7 +13,7 @@ class ObjectNotFoundError(Exception): class ObjectStorage(Protocol): - """Что ядру нужно от S3: бакет, запись и чтение объекта целиком.""" + """Что ядру нужно от S3: бакет, запись, чтение объекта целиком и порциями.""" bucket: str @@ -21,3 +22,9 @@ class ObjectStorage(Protocol): async def get_bytes(self, obj_name: str) -> bytes: """Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`.""" ... + + def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + """Объект порциями по `chunk_size` байт; отсутствие объекта — `ObjectNotFoundError` до первой порции. + + Закрытие итератора (`aclose`) освобождает соединение с S3.""" + ... diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 5efd649..e5be153 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -18,7 +18,9 @@ audit-trail «События файлов и документов»; design D-4) import hashlib import uuid +from collections.abc import AsyncIterator from dataclasses import dataclass +from typing import Literal from loguru import logger from sqlalchemy import func, select @@ -28,10 +30,10 @@ from starlette import status as http_status from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError -from src.common.filenames import safe_filename +from src.common.filenames import CONTENT_TYPES, content_disposition, extension, safe_filename from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings -from src.core.storage import ObjectStorage +from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import IN_WORK_STATUSES, VisibilityScope from src.models.consolidation import ConsolidationVersion @@ -65,6 +67,7 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE" STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего +FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" @@ -76,6 +79,13 @@ class StorageUnavailableError(DomainError): super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message) +class FileContentNotFoundError(DomainError): + """404 `FILE_CONTENT_NOT_FOUND`: файл есть в БД, но объекта нет в S3 (истёк или пропал).""" + + def __init__(self) -> None: + super().__init__(http_status.HTTP_404_NOT_FOUND, FILE_CONTENT_NOT_FOUND, "исходный файл недоступен в хранилище") + + @dataclass(frozen=True) class AcceptedFile: """Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя.""" @@ -106,6 +116,18 @@ class RejectedFile: ) +async def _chain(first: bytes, rest: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Первая (уже прочитанная) порция и остальной поток; при завершении, ошибке и обрыве потребителя поток S3 + закрывается (`aclose` — там `close` + `release` ответа), соединение не остаётся занятым.""" + try: + if first: + yield first + async for chunk in rest: + yield chunk + finally: + await rest.aclose() # type: ignore[attr-defined] + + def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str: """Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей).""" return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}" @@ -231,6 +253,36 @@ class PrimaryFilesService: (card,) = await self._cards([file]) return card + async def content( + self, file_id: uuid.UUID, disposition: Literal["attachment", "inline"] = "attachment" + ) -> tuple[AsyncIterator[bytes], str, str]: + """Исходный файл потоком: `(поток, Content-Type, Content-Disposition)` (spec documents-api, D-10). + + Файл любого статуса (в т. ч. `SUPERSEDED`) в области видимости; чужой и несуществующий — 404. Ключ и имя + читаются здесь, тело потока сессию не держит (ответ уходит после коммита запроса). Первая порция берётся до + отправки заголовков: отсутствие объекта в S3 — 404 `FILE_CONTENT_NOT_FOUND`, а не оборванный ответ. + Аудит не пишется.""" + file = await files_repo.get_file(self._session, self._scope, file_id) + if file is None: + raise NotFoundError(FILE_NOT_FOUND) + s3_object = await self._session.get(S3Object, file.s3_object_id) + if s3_object is None: + raise FileContentNotFoundError() + storage = self.require_storage() + iterator = storage.stream(s3_object.key) + try: + first = await anext(iterator, b"") + except ObjectNotFoundError: + raise FileContentNotFoundError() from None + except BaseException: + await iterator.aclose() # type: ignore[attr-defined] + raise + name = file.original_name or "" + suffix = extension(name) or file.file_type.value.lower() + header = content_disposition(name or f"file.{suffix}", disposition=disposition, ascii_fallback=f"file.{suffix}") + media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") + return _chain(first, iterator), media_type, header + async def delete(self, file_id: uuid.UUID) -> None: """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 108e1b9..b1caa39 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -207,7 +207,7 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC class FakeStorage: - """Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре.""" + """Заглушка `S3Storage` (протокол `ObjectStorage`): `put_object`/`get_bytes`/`stream`, объекты — в словаре.""" def __init__(self, bucket: str = "arm-ka") -> None: self.bucket = bucket @@ -222,6 +222,14 @@ class FakeStorage: except KeyError: raise ObjectNotFoundError(obj_name) from None + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + try: + data = self.objects[obj_name] + except KeyError: + raise ObjectNotFoundError(obj_name) from None + for start in range(0, len(data), chunk_size): + yield data[start : start + chunk_size] + @pytest_asyncio.fixture async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py new file mode 100644 index 0000000..f4e9256 --- /dev/null +++ b/api/tests/test_api_files_content.py @@ -0,0 +1,167 @@ +"""Исходный файл потоком (spec documents-api «Исходный файл»; design D-10).""" + +from collections.abc import AsyncIterator + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.storage import ObjectNotFoundError +from src.models.enums import FileStatus, FileType, RecognitionStatus +from src.models.files import S3Object, StoredFile +from src.services.primary_files import _chain +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import make_stored_file +from tests.test_api_documents import CLIENT_MANAGER, CURATOR_GO, KA_RF, RKA, _rostov_entity + +pytestmark = pytest.mark.integration +PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка порционной отдачи +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_FORBIDDEN = 403 + + +async def _file( + session: AsyncSession, storage: FakeStorage, name: str, file_type: FileType, *, entity_name: str = "ООО Файлы" +) -> StoredFile: + entity = await _rostov_entity(session, name=entity_name) + file, _ = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, original_name=name + ) + file.file_type = file_type + key = (await session.execute(select(S3Object.key).where(S3Object.id == file.s3_object_id))).scalar_one() + storage.objects[key] = PDF + await session.flush() + return file + + +async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "aep_6.pdf", FileType.PDF) + client: AsyncClient = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content", params={"disposition": "inline"}) + assert response.status_code == HTTP_OK + assert response.headers["content-type"] == "application/pdf" + assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" + assert response.content == PDF + + +async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "ОСВ_66 за 2024.xls", FileType.XLS) + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + assert response.headers["content-type"] == "application/vnd.ms-excel" + assert response.headers["content-disposition"].startswith( + "attachment; filename=\"file.xls\"; filename*=UTF-8''%D0%9E" + ) + storage.objects.clear() + missing = await client.get(f"/files/{file.id}/content") + assert missing.status_code == HTTP_NOT_FOUND and missing.json()["detail"]["code"] == "FILE_CONTENT_NOT_FOUND" + + +async def test_superseded_file_is_served(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "old.xlsx", FileType.XLSX) + file.status = FileStatus.SUPERSEDED + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + assert response.status_code == HTTP_OK and response.content == PDF + assert response.headers["content-type"] == ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +async def test_foreign_unknown_and_permission(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "a.pdf", FileType.PDF) + # RKA — только DOCUMENT_VIEW: доступ есть (тот же филиал); без права — 403 до видимости + assert (await (await auth_client(RKA)).get(f"/files/{file.id}/content")).status_code == HTTP_OK + assert (await (await auth_client(CURATOR_GO)).get(f"/files/{file.id}/content")).status_code == HTTP_FORBIDDEN + foreign = await (await auth_client(CLIENT_MANAGER)).get(f"/files/{file.id}/content") # другой филиал + assert foreign.status_code == HTTP_NOT_FOUND and foreign.json()["detail"]["code"] == "NOT_FOUND" + client = await auth_client(KA_RF) + unknown = await client.get("/files/00000000-0000-0000-0000-000000000000/content") + assert unknown.status_code == HTTP_NOT_FOUND + + +async def test_stream_error_before_first_chunk_is_object_not_found() -> None: + storage = FakeStorage() + with pytest.raises(ObjectNotFoundError): + await storage.stream("missing").__anext__() + + +async def test_stream_releases_on_error() -> None: + """Review Focus 4: при обрыве потребителя ответ S3 закрывается и отпускается.""" + from src.core.clients import S3Storage # noqa: PLC0415 + + class Content: + async def iter_chunked(self, size: int): # noqa: ANN202 + yield b"a" * size + yield b"b" * size + + class Response: + def __init__(self) -> None: + self.content = Content() + self.closed = self.released = False + + def close(self) -> None: + self.closed = True + + async def release(self) -> None: + self.released = True + + response = Response() + + class Client: + async def get_object(self, bucket: str, name: str) -> Response: + return response + + storage = S3Storage(Client(), "arm-ka") # type: ignore[arg-type] + stream = storage.stream("uploads/x", chunk_size=4) + assert await stream.__anext__() == b"aaaa" + await stream.aclose() # потребитель оборвал чтение + assert response.closed and response.released + + +class _Source: + """Источник порций с признаком закрытия (`finally` асинхронного генератора).""" + + def __init__(self, fail_after: int | None = None) -> None: + self.closed = False + self._fail_after = fail_after + self.iterator = self._run() + + async def _run(self) -> AsyncIterator[bytes]: + try: + for index in range(3): + if self._fail_after == index: + raise ConnectionError("обрыв S3") + yield b"x" + finally: + self.closed = True + + +async def test_chain_closes_source_when_consumer_stops() -> None: + """Обрыв клиента после первой порции: `aclose` обёртки закрывает исходный поток.""" + source = _Source() + first = await anext(source.iterator, b"") + chain = _chain(first, source.iterator) + assert await chain.__anext__() == b"x" + await chain.aclose() + assert source.closed + + +async def test_chain_closes_source_on_error_and_yields_all() -> None: + source = _Source(fail_after=2) + chain = _chain(await anext(source.iterator, b""), source.iterator) + with pytest.raises(ConnectionError): + _ = [chunk async for chunk in chain] + assert source.closed + complete = _Source() + assert b"".join([chunk async for chunk in _chain(await anext(complete.iterator, b""), complete.iterator)]) == b"xxx" + assert complete.closed diff --git a/api/tests/test_clients.py b/api/tests/test_clients.py index 4f7c6eb..c793767 100644 --- a/api/tests/test_clients.py +++ b/api/tests/test_clients.py @@ -1,6 +1,7 @@ """`S3Storage.get_bytes` на заглушке Minio (без сети): чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`.""" +from collections.abc import AsyncIterator from typing import Any from unittest.mock import MagicMock @@ -11,11 +12,21 @@ from src.core.clients import S3Storage from src.core.storage import ObjectNotFoundError +class StubContent: + def __init__(self, data: bytes) -> None: + self._data = data + + async def iter_chunked(self, size: int) -> AsyncIterator[bytes]: + for start in range(0, len(self._data), size): + yield self._data[start : start + size] + + class StubResponse: """Ответ `aiohttp` с флагами закрытия.""" def __init__(self, data: bytes, *, fail_read: bool = False) -> None: self._data = data + self.content = StubContent(data) self._fail_read = fail_read self.closed = False self.released = False @@ -83,3 +94,24 @@ async def test_get_bytes_other_s3_error_is_not_swallowed() -> None: with pytest.raises(S3Error) as caught: await _storage(StubMinio(error=_s3_error("AccessDenied"))).get_bytes("k") assert caught.value.code == "AccessDenied" + + +async def test_stream_yields_chunks_and_releases_response() -> None: + response = StubResponse(b"abcdefghij") + + chunks = [chunk async for chunk in _storage(StubMinio(response)).stream("k", chunk_size=4)] + + assert chunks == [b"abcd", b"efgh", b"ij"] + assert response.closed is True + assert response.released is True + + +async def test_stream_no_such_key_is_object_not_found_before_first_chunk() -> None: + with pytest.raises(ObjectNotFoundError): + await _storage(StubMinio(error=_s3_error("NoSuchKey"))).stream("missing").__anext__() + + +async def test_stream_other_s3_error_is_not_swallowed() -> None: + with pytest.raises(S3Error) as caught: + await _storage(StubMinio(error=_s3_error("AccessDenied"))).stream("k").__anext__() + assert caught.value.code == "AccessDenied" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 75738b9..8928736 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -48,6 +48,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { # primary-files, documents-api (Срез 2c) "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, "/api/v1/files/{file_id}": {"get", "delete"}, + "/api/v1/files/{file_id}/content": {"get"}, "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, -- 2.47.2 From 58f039a91d1ee439a332bc2429f01f2596ad7bf6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:08:29 +0400 Subject: [PATCH 18/36] =?UTF-8?q?feat(users):=20GET=20/users/brief=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=B0=D1=83?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D1=86=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20(D-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен эндпоинт GET /api/v1/users/brief для получения краткого справочника пользователей (id, ФИО, статус активности) по списку id. Доступен любому аутентифицированному пользователю без отдельного права. Максимум 200 id, иначе 422 TOO_MANY_IDS; некорректный UUID — 422 INVALID_IDS. - Создан api/src/api/endpoints/users.py с маршрутом /brief - Добавлен метод users_brief в repositories/users.py - Добавлена схема UserBriefOut в schemas/admin.py - Подключён маршрут users в build_api_router ДО admin.router - Обновлены тесты OpenAPI: добавлены маршрут и префикс /users/brief - Написаны интеграционные тесты в test_api_users_brief.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/users.py | 63 +++++++++++++++++++++++++++++++ api/src/api/router.py | 3 +- api/src/repositories/users.py | 8 ++++ api/src/schemas/admin.py | 10 +++++ api/tests/test_api_users_brief.py | 37 ++++++++++++++++++ api/tests/test_openapi_routes.py | 4 ++ 6 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 api/src/api/endpoints/users.py create mode 100644 api/tests/test_api_users_brief.py diff --git a/api/src/api/endpoints/users.py b/api/src/api/endpoints/users.py new file mode 100644 index 0000000..9fd8da8 --- /dev/null +++ b/api/src/api/endpoints/users.py @@ -0,0 +1,63 @@ +"""`GET /api/v1/users/brief` — краткий справочник пользователей по id (spec users-admin, D-12). + +Доступен любому аутентифицированному пользователю без отдельного права. Возвращает id, ФИО и статус активности +найденных пользователей (включая неактивных); неизвестные id не включаются, максимум 200 id. +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.common.exceptions import DomainValidationError +from src.repositories import users +from src.schemas.admin import UserBriefOut + +router = APIRouter(prefix="/users", tags=["users"]) + +MAX_BRIEF_IDS = 200 + + +def _parse_ids(ids_str: str) -> list[uuid.UUID]: + """Разбор строки id, отфильтровка пустых, валидация формата UUID.""" + if not ids_str.strip(): + return [] + result = [] + for id_part in ids_str.split(","): + id_str = id_part.strip() + if not id_str: + continue + try: + result.append(uuid.UUID(id_str)) + except ValueError as e: + raise DomainValidationError("INVALID_IDS", "некорректный UUID в списке") from e + return result + + +@router.get( + "/brief", + response_model=list[UserBriefOut], + summary="Краткие данные пользователей", +) +async def get_users_brief( + principal: Annotated[Principal, Depends(current_user)], + session: SessionDep, + ids: str = Query(..., description="Список id через запятую"), +) -> list[UserBriefOut]: + """Краткие данные пользователей по id: id, ФИО, статус активности. + + Возвращает только найденные пользователей (включая неактивных); неизвестные id отсутствуют. + Максимум 200 id; свыше 200 — 422 `TOO_MANY_IDS`. Некорректный UUID — 422 `INVALID_IDS`. + """ + # Разбор и валидация id + parsed_ids = _parse_ids(ids) + + # Проверка лимита + if len(parsed_ids) > MAX_BRIEF_IDS: + raise DomainValidationError("TOO_MANY_IDS", f"максимум {MAX_BRIEF_IDS} id") + + # Получение пользователей + users_list = await users.users_brief(session, parsed_ids) + return [UserBriefOut.model_validate(user) for user in users_list] diff --git a/api/src/api/router.py b/api/src/api/router.py index 8460dff..acc2f81 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -8,7 +8,7 @@ dev-эмитент токенов — только при `AUTH_DEV_ISSUER and D from fastapi import APIRouter -from src.api.endpoints import admin, consolidations, directories, files, me +from src.api.endpoints import admin, consolidations, directories, files, me, users from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -20,6 +20,7 @@ def build_api_router(settings: Settings) -> APIRouter: api_router = APIRouter(prefix=API_PREFIX) api_router.include_router(me.router) api_router.include_router(directories.router) + api_router.include_router(users.router) api_router.include_router(admin.router) api_router.include_router(files.router) api_router.include_router(consolidations.router) diff --git a/api/src/repositories/users.py b/api/src/repositories/users.py index 044a57e..aa07668 100644 --- a/api/src/repositories/users.py +++ b/api/src/repositories/users.py @@ -259,3 +259,11 @@ async def count_active_users_with_permission( if excluding_role is not None: stmt = stmt.where(Role.id != excluding_role) return (await session.execute(stmt)).scalar_one() + + +async def users_brief(session: AsyncSession, ids: Sequence[uuid.UUID]) -> list[AppUser]: + """Краткие данные пользователей по id (включая неактивных); неизвестные id не возвращаются.""" + if not ids: + return [] + stmt = select(AppUser).where(AppUser.id.in_(ids)).order_by(AppUser.id) + return list((await session.execute(stmt)).scalars().all()) diff --git a/api/src/schemas/admin.py b/api/src/schemas/admin.py index 8209028..467094a 100644 --- a/api/src/schemas/admin.py +++ b/api/src/schemas/admin.py @@ -148,3 +148,13 @@ class PermissionsSet(BaseModel): model_config = ConfigDict(extra="forbid") permissions: list[str] + + +class UserBriefOut(BaseModel): + """Краткие данные пользователя (справочник для загрузивших файлы; доступен любому аутентифицированному).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + full_name: str + is_active: bool diff --git a/api/tests/test_api_users_brief.py b/api/tests/test_api_users_brief.py new file mode 100644 index 0000000..794cbd6 --- /dev/null +++ b/api/tests/test_api_users_brief.py @@ -0,0 +1,37 @@ +"""Краткий справочник пользователей (spec users-admin «Краткий справочник пользователей»; D-12).""" + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.users import MAX_BRIEF_IDS +from src.models.access import AppUser +from tests.conftest import AuthClientFactory + +pytestmark = pytest.mark.integration + +HTTP_OK = 200 +HTTP_UNPROCESSABLE = 422 +KA_RF = "ka_rf@armka.local" + + +async def test_brief_for_non_admin(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + users = (await seeded.execute(select(AppUser).order_by(AppUser.email).limit(2))).scalars().all() + unknown = uuid.uuid4() + client = await auth_client(KA_RF) # без USERS_ADMIN + ids = ",".join([*(str(user.id) for user in users), str(unknown)]) + response = await client.get("/users/brief", params={"ids": ids}) + assert response.status_code == HTTP_OK + body = {item["id"]: item for item in response.json()} + assert set(body) == {str(user.id) for user in users} + assert all(set(item) == {"id", "full_name", "is_active"} for item in body.values()) + + +async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ARG001 + client = await auth_client(KA_RF) + too_many = ",".join(str(uuid.uuid4()) for _ in range(MAX_BRIEF_IDS + 1)) + response = await client.get("/users/brief", params={"ids": too_many}) + assert response.status_code == HTTP_UNPROCESSABLE and response.json()["detail"]["code"] == "TOO_MANY_IDS" + assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == HTTP_UNPROCESSABLE diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 8928736..8d426bb 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -38,6 +38,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"}, # directory-import f"{D}/import": {"post"}, + # users + "/api/v1/users/brief": {"get"}, # users-admin f"{A}/users": {"get", "post"}, f"{A}/users/{{user_id}}": {"patch"}, @@ -75,6 +77,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { CHECKED_PREFIXES = ( D, A, + "/api/v1/users", "/api/v1/legal-entities", "/api/v1/files", "/api/v1/documents", @@ -88,6 +91,7 @@ SINGLE_MODELS = [ "CompanyGroupOut", "LegalEntityOut", "UserOut", + "UserBriefOut", "RoleOut", "ImportReportOut", "FileOut", -- 2.47.2 From 60785329829222b0d6bea455da68c14f3c156d96 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:16:47 +0400 Subject: [PATCH 19/36] =?UTF-8?q?feat(directories):=20documents=5Fcount=20?= =?UTF-8?q?=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=AE=D0=9B=20(D-?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено поле documents_count в LegalEntityOut, содержащее число документов файлов ЮЛ со статусом не SUPERSEDED. Функция documents_count_by_entity в репозитории получает счётчики одним подзапросом для эффективности. Используется в списке и карточке через сервис. Тест проверяет, что заменённые файлы не учитываются в подсчёте. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../endpoints/directories/legal_entities.py | 31 +++++++++++--- api/src/repositories/directories.py | 20 ++++++++- api/src/schemas/directories.py | 6 ++- api/src/services/legal_entities.py | 4 ++ api/tests/test_api_legal_entities.py | 41 ++++++++++++++++++- 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 62f1c86..3a929c5 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -36,6 +36,13 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams): """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" +def _out(entity, documents_count: int = 0) -> LegalEntityOut: + """Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов.""" + out = LegalEntityOut.model_validate(entity) + out.documents_count = documents_count + return out + + @router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП") async def list_legal_entities( service: LegalEntityServiceDep, @@ -45,8 +52,10 @@ async def list_legal_entities( page = PageParams(limit=query.limit, offset=query.offset) flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields))) items, total = await service.page(flt, page) + entity_ids = [entity.id for entity in items] + counts = await service.documents_count_by_entities(entity_ids) return Page[LegalEntityOut]( - items=[LegalEntityOut.model_validate(entity) for entity in items], + items=[_out(entity, counts.get(entity.id, 0)) for entity in items], total=total, limit=page.limit, offset=page.offset, @@ -58,13 +67,17 @@ async def list_legal_entities( ) async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: """Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12.""" - return LegalEntityOut.model_validate(await service.create(data)) + entity = await service.create(data) + return _out(entity, 0) @router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП") async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail: """ЮЛ с текущими членствами ГСК/ГСЗ и алиасами.""" - return await service.get(entity_id) + detail = await service.get(entity_id) + counts = await service.documents_count_by_entities([entity_id]) + detail.documents_count = counts.get(entity_id, 0) + return detail @router.patch("/{entity_id}", response_model=LegalEntityOut, dependencies=EDIT, summary="Изменить ЮЛ/ИП") @@ -72,7 +85,9 @@ async def update_legal_entity( entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep ) -> LegalEntityOut: """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" - return LegalEntityOut.model_validate(await service.update(entity_id, data)) + entity = await service.update(entity_id, data) + counts = await service.documents_count_by_entities([entity_id]) + return _out(entity, counts.get(entity_id, 0)) @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @@ -80,7 +95,9 @@ async def move_legal_entity( entity_id: uuid.UUID, data: LegalEntityMove, service: LegalEntityServiceDep ) -> LegalEntityOut: """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" - return LegalEntityOut.model_validate(await service.move(entity_id, data)) + entity = await service.move(entity_id, data) + counts = await service.documents_count_by_entities([entity_id]) + return _out(entity, counts.get(entity_id, 0)) @router.post( @@ -91,7 +108,9 @@ async def withdraw_legal_entity( ) -> LegalEntityOut: """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства закрываются.""" - return LegalEntityOut.model_validate(await service.withdraw(entity_id, data)) + entity = await service.withdraw(entity_id, data) + counts = await service.documents_count_by_entities([entity_id]) + return _out(entity, counts.get(entity_id, 0)) @router.get( diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index b91aafa..f60d681 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -24,7 +24,8 @@ from src.models.directories import ( RelatedGroup, RelatedGroupMember, ) -from src.models.enums import RelatedGroupKind +from src.models.enums import FileStatus, RelatedGroupKind +from src.models.files import Document, StoredFile LIKE_ESCAPE = "\\" ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов @@ -189,6 +190,23 @@ async def get_legal_entity_by_inn(session: AsyncSession, inn: str) -> LegalEntit return (await session.execute(select(LegalEntity).where(LegalEntity.inn == inn))).scalar_one_or_none() +async def documents_count_by_entity(session: AsyncSession, entity_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + """Число документов файлов каждой ЮЛ со статусом файла не SUPERSEDED (D-13). + + Возвращает словарь {entity_id: count}; отсутствующих ЮЛ — не в словаре. + """ + if not entity_ids: + return {} + stmt = ( + select(Document.legal_entity_id, func.count(Document.id).label("count")) + .join(StoredFile, Document.stored_file_id == StoredFile.id) + .where(Document.legal_entity_id.in_(entity_ids), StoredFile.status != FileStatus.SUPERSEDED) + .group_by(Document.legal_entity_id) + ) + rows = (await session.execute(stmt)).all() + return {entity_id: count for entity_id, count in rows} + + async def legal_entity_crm_id_taken( session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None ) -> bool: diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 9368c60..a731caa 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -208,7 +208,10 @@ class LegalEntityWithdraw(BaseModel): class LegalEntityOut(BaseModel): - """ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`.""" + """ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`. + + `documents_count` — число документов файлов ЮЛ со статусом файла не SUPERSEDED (D-13). + """ model_config = ConfigDict(from_attributes=True) @@ -223,6 +226,7 @@ class LegalEntityOut(BaseModel): valid_from: datetime.datetime created_at: datetime.datetime | None updated_at: datetime.datetime | None + documents_count: int = 0 @computed_field # type: ignore[prop-decorator] @property diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index e2732e8..6247b63 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -275,6 +275,10 @@ class LegalEntityService: offset=page.offset, ) + async def documents_count_by_entities(self, entity_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + """Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED).""" + return await repo.documents_count_by_entity(self._session, entity_ids) + async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity: entity = await repo.get_legal_entity(self._session, self._scope, entity_id) if entity is None: diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 95396f8..8ce5a1b 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -20,9 +20,23 @@ from src.models.directories import ( RegionalOffice, RelatedGroupMember, ) -from src.models.enums import AuditAction, AuditSection, EntityType, RelatedGroupKind +from src.models.enums import ( + AuditAction, + AuditSection, + EntityType, + FileStatus, + RecognitionStatus, + RelatedGroupKind, +) from tests.conftest import AuthClientFactory -from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group +from tests.factories import ( + make_company_group, + make_document, + make_legal_entity, + make_membership, + make_related_group, + make_stored_file, +) HTTP_OK = 200 HTTP_CREATED = 201 @@ -577,3 +591,26 @@ async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, see assert isinstance(response.json()["detail"], list) count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() assert count == before + + +@pytest.mark.integration +async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """D-13: documents_count в списке и карточке ЮЛ — число документов файлов со статусом не SUPERSEDED.""" + entity = await make_legal_entity(seeded, name="ООО Счётчик") # монозаёмщик + live, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + await make_document(seeded, file=live, job=job, account="60") + await make_document(seeded, file=live, job=job, account="62") + old, old_job = await make_stored_file( + seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done + ) + await make_document(seeded, file=old, job=old_job, account="66") + + client = await auth_client("ka_go@armka.local") + listed = (await client.get("/directories/legal-entities", params={"q": "Счётчик"})).json() + expected_count = 2 # два документа в не заменённом файле, один в заменённом (не считается) + assert listed["items"][0]["documents_count"] == expected_count + + card = (await client.get(f"/directories/legal-entities/{entity.id}")).json() + assert card["documents_count"] == expected_count -- 2.47.2 From 0c203c1d91636a28f90a7ea4dc51e7d6c179d905 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:23:36 +0400 Subject: [PATCH 20/36] =?UTF-8?q?feat(consolidations):=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=20=D0=B2=20CSV=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BC=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=D0=B0,=20BOM,=20=C2=AB;=C2=BB=20(D-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлен экспорт чек-листа в CSV-формате при параметре ?format=csv: - Реализована функция render_checklist_csv в src/domain/checklist_csv.py - UTF-8 кодировка с BOM, разделитель «;», окончание строк CRLF - Подписи статусов: CONFIRMED→«Загружен», PROCESSING→«Обработка», NEEDS_CHECK/RECOGNIZED→«Требуется проверка», ERROR→«Ошибка загрузки», MISSING→«Отсутствует» - При missing_quarters добавляется подстрока « (нет: Q1, Q2)» - Имя файла: checklist-<ГК>-<год>-<квартал>.csv с поддержкой RFC 5987 - Используется существующая функция content_disposition из filenames.py - Добавлена функция version_title в ConsolidationService для получения названия ГК, года и квартала версии Тесты: - test_domain_checklist_csv.py: юнит-тесты функций render и filename - test_api_consolidation_checklist.py: интеграционный тест CSV-ответа Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../api/endpoints/consolidations/checklist.py | 35 +++++++-- api/src/domain/checklist_csv.py | 76 +++++++++++++++++++ api/src/services/consolidations.py | 14 ++++ api/tests/test_api_consolidation_checklist.py | 19 +++++ api/tests/test_domain_checklist_csv.py | 37 +++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 api/src/domain/checklist_csv.py create mode 100644 api/tests/test_domain_checklist_csv.py diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py index 92ab0bb..474f178 100644 --- a/api/src/api/endpoints/consolidations/checklist.py +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -2,12 +2,14 @@ «Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404.""" import uuid -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Query, Response, status from src.api.dependencies.auth import require from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.common.filenames import content_disposition +from src.domain.checklist_csv import checklist_filename, render_checklist_csv from src.schemas.common import ErrorDetail from src.schemas.consolidations import ChecklistOut @@ -26,6 +28,29 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = { responses=NOT_FOUND, summary="Чек-лист комплекта документов версии", ) -async def get_checklist(version_id: uuid.UUID, service: ConsolidationServiceDep) -> ChecklistOut: - """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`.""" - return await service.checklist(version_id) +async def get_checklist( + version_id: uuid.UUID, + service: ConsolidationServiceDep, + format: Literal["json", "csv"] = Query("json"), +) -> ChecklistOut | Response: + """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`. + + При `?format=csv` возвращает чек-лист файлом CSV (UTF-8 с BOM, разделитель `;`, CRLF). + """ + checklist = await service.checklist(version_id) + + if format == "csv": + group_name, year, quarter = await service.version_title(version_id) + filename = checklist_filename(group_name, year, quarter) + csv_content = render_checklist_csv(checklist) + return Response( + content=csv_content, + media_type="text/csv; charset=utf-8", + headers={ + "Content-Disposition": content_disposition( + filename, disposition="attachment", ascii_fallback="checklist.csv" + ) + }, + ) + + return checklist diff --git a/api/src/domain/checklist_csv.py b/api/src/domain/checklist_csv.py new file mode 100644 index 0000000..487a910 --- /dev/null +++ b/api/src/domain/checklist_csv.py @@ -0,0 +1,76 @@ +"""Экспорт чек-листа в CSV (spec consolidations-api «Чек-лист комплекта»; D-13). + +`render_checklist_csv` — чистая функция без БД: строит CSV буфер, кодирует в UTF-8 с BOM. +`checklist_filename` — формирует имя файла для Content-Disposition. +""" + +import csv +import io + +from src.domain.checklist import CellStatus +from src.models.enums import DocumentType +from src.schemas.consolidations import ChecklistOut + +# Подписи статусов ячеек (макет UI) +_CELL_STATUS_LABELS = { + CellStatus.CONFIRMED: "Загружен", + CellStatus.PROCESSING: "Обработка", + CellStatus.NEEDS_CHECK: "Требуется проверка", + CellStatus.RECOGNIZED: "Требуется проверка", + CellStatus.ERROR: "Ошибка загрузки", + CellStatus.MISSING: "Отсутствует", +} + + +# Подписи правил +def _rule_label(document_type: DocumentType, account: str | None) -> str: + """Подпись правила для заголовка CSV.""" + if document_type == DocumentType.BALANCE: + return "Бух. баланс" + elif document_type == DocumentType.INCOME: + return "ОФР" + elif document_type == DocumentType.OSV: + return f"ОСВ {account}" if account else "ОСВ" + else: + return document_type.value + + +def render_checklist_csv(checklist: ChecklistOut) -> bytes: + """Чек-лист в CSV: UTF-8 с BOM, разделитель `;`, CRLF. + + Заголовок: Организация;ИНН;<правило>… + Строки: имя ЮЛ, ИНН, ячейки по порядку правил. + Статус ячейки: подпись из CELL_STATUS_LABELS, с " (нет: Q1, Q2)" при missing_quarters. + """ + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") + + # Заголовок + rule_labels = [_rule_label(rule.document_type, rule.account) for rule in checklist.rules] + header = ["Организация", "ИНН"] + rule_labels + writer.writerow(header) + + # Строки + for row in checklist.rows: + cell_values = [] + for cell, _rule in zip(row.cells, checklist.rules, strict=True): + # Получить подпись статуса + label = _CELL_STATUS_LABELS.get(cell.status, cell.status.value) + + # Добавить missing_quarters если есть + if cell.missing_quarters: + quarters_str = ", ".join(str(q.value) for q in cell.missing_quarters) + label = f"{label} (нет: {quarters_str})" + + cell_values.append(label) + + writer.writerow([row.legal_entity_name, row.legal_entity_inn] + cell_values) + + # Кодировать в UTF-8 с BOM + csv_text = buffer.getvalue() + return ("" + csv_text).encode("utf-8") + + +def checklist_filename(group_name: str, year: int, quarter: str) -> str: + """Имя файла для Content-Disposition: checklist-<ГК>-<год>-<квартал>.csv""" + return f"checklist-{group_name}-{year}-{quarter}.csv" diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 5080738..01e54aa 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -191,6 +191,20 @@ class ConsolidationService: ], ) + async def version_title(self, version_id: uuid.UUID) -> tuple[str, int, str]: + """Название ГК, год и квартал версии для имени файла экспорта (CSV чек-листа). + + Версия вне видимости и несуществующая — один и тот же 404 как в `checklist`. + """ + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) + if group is None: + raise NotFoundError(VERSION_NOT_FOUND) + return group.name, consolidation.year, consolidation.quarter.value + async def _checklist_cells( self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity] ) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]: diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index d37a093..5924a05 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -241,3 +241,22 @@ async def test_checklist_unconfirmed_of_entity_outside_perimeter_has_no_effect( assert [row["legal_entity_name"] for row in body["rows"]] == ["ООО А", "ООО Б"] assert all({cell["status"] for cell in row["cells"]} == {"MISSING"} for row in body["rows"]) + + +@pytest.mark.integration +async def test_checklist_csv_format(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Чек-лист в CSV: format=csv → text/csv, BOM, Content-Disposition с filename*.""" + version, (first, _) = await _bound_world(seeded) + client = await auth_client(KA_RF) + + response = await client.get(f"/consolidations/versions/{version.id}/checklist?format=csv") + + assert response.status_code == HTTP_OK, response.text + assert response.headers["content-type"].startswith("text/csv") + assert response.content.startswith("".encode()) + lines = response.content.decode("utf-8-sig").splitlines() + assert lines[0] == "Организация;ИНН;Бух. баланс;ОФР;ОСВ 01;ОСВ 58;ОСВ 60;ОСВ 62;ОСВ 66;ОСВ 67;ОСВ 76;ОСВ 90" + assert "Загружен" in lines[1] # первого ЮЛ подтверждён ОСВ 60 + disposition = response.headers.get("content-disposition", "") + assert "filename*=UTF-8''" in disposition + assert "checklist-" in disposition diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py new file mode 100644 index 0000000..d5c554a --- /dev/null +++ b/api/tests/test_domain_checklist_csv.py @@ -0,0 +1,37 @@ +"""CSV чек-листа (spec consolidations-api «Чек-лист комплекта»; D-13).""" + +import uuid + +from src.domain.checklist import CellStatus +from src.domain.checklist_csv import checklist_filename, render_checklist_csv +from src.models.enums import DocumentType, PeriodQuarter +from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut + + +def test_render() -> None: + checklist = ChecklistOut( + rules=[ + ChecklistRuleOut(document_type=DocumentType.BALANCE, account=None, is_mandatory=True), + ChecklistRuleOut(document_type=DocumentType.OSV, account="60", is_mandatory=True), + ], + rows=[ + ChecklistRowOut( + legal_entity_id=uuid.uuid4(), + legal_entity_name='ООО "Альфа; Бета"', + legal_entity_inn="7700000000", + cells=[ + ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]), + ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]), + ], + ) + ], + ) + content = render_checklist_csv(checklist) + assert content.startswith("".encode()) + lines = content.decode("utf-8-sig").splitlines() + assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60" + assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' + + +def test_filename() -> None: + assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" -- 2.47.2 From dea64c9f1de39b46636be04da4b5f742578886cc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:30:00 +0400 Subject: [PATCH 21/36] =?UTF-8?q?docs(arm-core):=20README,=20CLAUDE.md,=20?= =?UTF-8?q?tasks.md=20=D0=BF=D0=BE=D0=B4=20=D0=A1=D1=80=D0=B5=D0=B7=203b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 22 +++- README.md | 107 +++++++++++++++--- .../changes/slice-3b-statements-api/tasks.md | 21 +++- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b617ae..e26fdc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,9 @@ ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ, R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py` - (чек-лист комплекта, ячейки `CellStatus`); `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки + (чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6), + `statement_checks.py` (проверки §7 по словарю `api/rules/statement_checks.yaml`), `checklist_csv.py` (CSV чек-листа); + `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. - **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; `storage`/`nats` — из `request.app.state`. **Сессия** — @@ -83,6 +85,24 @@ взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`, `FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется (OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится. +- **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые + функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать); + документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409 + `DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — `domain/statement_checks.py` по словарю `api/rules/statement_checks.yaml`; **файл — + копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок). + Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт — + `services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` → + `DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка + рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус + документа. **Порядок блокировок «файл → документ»**, шапка (`meta`) читается ПОД блокировкой с `refresh=True` — иначе в сессии + остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и + пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) + не блокируют. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, + без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + + `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; + имя — RFC 5987). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — + `?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения — + `openspec/changes/slice-3b-statements-api/design.md`. - **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export, mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard` (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), diff --git a/README.md b/README.md index d18f7cb..2275c76 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и замена», «Ограничения Среза 2c», «Сквозной прогон (e2e)». +Срез 3b («формы ББ/ОФР в ядре и API до сводной ОСВ»): маппинг форм ББ/ОФР из результата парсера в ядро, форма для +экрана «Проверка распознавания» с проверками §7, правка формы до подтверждения и акцепт проверок, условия подтверждения +формы, комментарии к документам, исходный файл потоком, имена пользователей, `documents_count` у ЮЛ, CSV чек-листа. +Спецификация — `openspec/changes/slice-3b-statements-api/`; разделы ниже: «API файлов и документов», «Формы ББ/ОФР (Срез 3b)», +«Ошибки API». + Срез 4 («консолидация»): схема по DBML v5.16, версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx. Спецификация — `openspec/changes/slice-4-consolidation/`; раздел ниже: «Консолидация (Срез 4)». @@ -232,9 +238,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `DELETE /directories/related-groups/{group_id}` | `DIRECTORY_DELETE` | удалить **пустую** ГСК/ГСЗ (`GROUP_NOT_EMPTY`) | | `POST /directories/related-groups/{group_id}/members` | `DIRECTORY_EDIT` | добавить ЮЛ (только из той же ГК) | | `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | `DIRECTORY_EDIT` | исключить ЮЛ (членство закрывается, не удаляется) | -| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset` | +| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset`; в каждой записи `documents_count` | | `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) | -| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами и алиасами) / изменить | +| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами, алиасами и `documents_count`) / изменить | | `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются | | `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` | | `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми | @@ -243,6 +249,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `POST /directories/import` | `DIRECTORY_ADMIN` | импорт выгрузки ЕЦП CRM (см. ниже) | | `GET /admin/users`, `POST /admin/users`, `PATCH /admin/users/{user_id}`, `PUT /admin/users/{user_id}/roles` | `USERS_ADMIN` | пользователи и их роли (см. «Администрирование») | | `GET /admin/roles`, `GET /admin/permissions`, `PUT /admin/roles/{code}/permissions` | `USERS_ADMIN` | матрица «роль × право» | +| `GET /users/brief?ids=,` | любой аутентифицированный (права не нужны) | `[{id, full_name, is_active}]` для найденных пользователей (в т. ч. неактивных; неизвестные id пропускаются) — чтобы фронт показывал имена вместо UUID (`confirmed_by`, `locked_by`, авторы комментариев); до 200 id, больше — `TOO_MANY_IDS`, некорректный UUID — `INVALID_IDS` | + +`documents_count` у ЮЛ — число документов его файлов, кроме файлов `SUPERSEDED` (подсчёт одним запросом на страницу). Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу `{items, total, limit, offset}` (`limit` 1–200, по умолчанию 50); список ГСК/ГСЗ одной ГК — простой массив. Каждая мутация пишет `audit_event` в той же транзакции, что и @@ -476,12 +485,12 @@ S3 (storage); без S3 — предупреждение в логе, цикл отвергаются); 2. читает объект из S3 **вне транзакции** и валидирует `RecognitionResult` v1.x (`job_id`/`file_id` должны совпасть с заданием); 3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт - `document` на каждый лист ОСВ и пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`, + `document` на каждый лист ОСВ и на каждую форму ББ/ОФР (шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`, `account_id`, `counterparty_id`), пишет `recognition_job.group_schema` и `metrics.mapping_warnings`, переводит файл в `PROCESSED` и выполняет автозамену (см. «Удаление и замена»). Ошибки: **неповторяемые** (ключ вне папки задания, нет объекта, результат другого задания, невалидный результат, нет -листов ОСВ) сразу переводят файл в `ERROR` с `metrics.mapping_error`; **повторяемые** (БД, S3) откатывают транзакцию, +документов) сразу переводят файл в `ERROR` с `metrics.mapping_error`; **повторяемые** (БД, S3) откатывают транзакцию, увеличивают `metrics.mapping_attempts`, цикл спит `MAPPING_POLL_SECONDS` и пробует снова; после `MAPPING_MAX_ATTEMPTS` — `ERROR`. Пока попытки не исчерпаны, файл остаётся `PROCESSING`; повторный маппинг начинается с очистки строк файла, дублей нет. @@ -507,13 +516,64 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | | `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` | -| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM` | +| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` | +| `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») | +| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` | +| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) | +| `GET /documents/{document_id}/comments` | `DOCUMENT_VIEW` | комментарии по возрастанию `created_at`: `{id, text, author {id, full_name}, created_at}` | +| `POST /documents/{document_id}/comments` | `DOCUMENT_EDIT` | добавить комментарий (`{text}`, 1–4000 символов после обрезки пробелов), 201; в любом статусе документа | +| `GET /files/{file_id}/content` | `DOCUMENT_VIEW` | исходный файл потоком из S3; `?disposition=attachment` (по умолчанию) или `inline`; имя — `Content-Disposition` по RFC 5987 | + +Карточка документа и документ в карточке файла несут `comments_count`. Просмотр и скачивание первички (`/files/{id}/content`) +и CSV чек-листа в аудит не пишутся (как экспорт сводной ОСВ). Чужой и несуществующий файл — 404 `NOT_FOUND`; файл есть, а объекта +в S3 нет (истёк или пропал) — 404 `FILE_CONTENT_NOT_FOUND`; S3 не подключено — 503 `STORAGE_UNAVAILABLE`. Страницы — `{items, total, limit, offset}`. Суммы строк — десятичные значения без округления, как в результате парсера. Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, -`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, -`DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`). +`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`, +`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`, +`CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел +`PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт). + +## Формы ББ/ОФР (Срез 3b) + +Цикл маппинга разбирает не только листы ОСВ, но и формы ББ/ОФР результата парсера: на каждую форму создаются `document` +(`BALANCE`/`INCOME`) и шапка `financial_statement_meta` с колонками, строками и значениями (`financial_statement_column`, +`_line`, `_value`). Первичка не мутируется: правка аналитика пишется рядом (`is_modified`, `original_amount`, +`modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой +поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений +проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет +предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 (`domain/statement_checks.py`) считаются на лету по +текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится +в обоих репозиториях). + +- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`, + `report_date`, `units` вида `{value, original_value, verified, modified}` (у ИНН ещё `confidence`) и справочные поля формы + (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `checks` — + `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной + проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. + Подтверждённая форма читается. +- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` + (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков). Правится только неподтверждённая форма + (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — + 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, + одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно + событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН + пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. + Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`. +- **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок + (`checks_accepted_by/at/comment`). Нарушений нет — 409 `NOTHING_TO_ACCEPT`. Акцепт сбрасывается любой правкой формы. +- **Подтверждение формы** (`POST /documents/{id}/confirm`) дополнительно требует определённый период (иначе 409 + `PERIOD_REQUIRED`) и пройденные или акцептованные жёсткие проверки §7 (иначе 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` — + `{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`) + подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ. +- **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. + Комментарии аудит не пишут; при удалении файла комментарии его документов остаются в таблице `comment` без владельца + (полиморфная связь без FK, решение №18; наружу не отдаются). + +Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным — +`src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`. ## Удаление и замена @@ -538,8 +598,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл - Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон. - Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены (Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы. -- Маппер Среза 2c разбирает только листы ОСВ (`osvDocument`); ББ/ОФР и результаты `scan-gateway` — следующие срезы, - прочие документы результата (BALANCE/INCOME/OTHER) игнорируются без предупреждения. +- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). + Результаты `scan-gateway` (PDF/изображения) и документы `OTHER` по-прежнему игнорируются без предупреждения. - Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД» получают `account_root = NULL`; `account_id` ищется точным совпадением с планом счетов seed, субсчетов в seed неполный набор — часть строк остаётся с `account_id = NULL`. @@ -547,8 +607,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл - ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга, поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца). - `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы, а их файл удалить - нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `financial_statement_meta` и `ex_snapshot_item` - — Срезы 3 и 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). + нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). - Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства. @@ -574,7 +633,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) | | `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | | `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) | -| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки | +| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV | | `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры (`limit` ≤ 2000, по умолчанию 500) | | `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]` | | `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | @@ -631,6 +690,13 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` `ERROR` (последний файл ЮЛ в `ERROR`), `MISSING`; `missing_quarters` — пропущенные кварталы при частичном покрытии ОСВ. `PROCESSING` ставится по всем недостающим столбцам ЮЛ, пока файл не смаплен (до маппинга счёт файла неизвестен). +`GET …/checklist?format=csv` (Срез 3b, право `CONSOLIDATION_VIEW`) отдаёт чек-лист файлом `text/csv`: UTF-8 с BOM, разделитель `;`, +строки через CRLF, заголовок — «Организация;ИНН;<правило>…» (подписи правил: «Бух. баланс», «ОФР», «ОСВ 66»), строки — ЮЛ, в ячейках — подписи +статусов («Загружен», «Обработка», «Требуется проверка», «Ошибка загрузки», «Отсутствует»; при пропущенных кварталах — +«… (нет: Q1, Q2)»); имя файла — `checklist-<ГК>-<год>-<квартал>.csv` +в `Content-Disposition` по RFC 5987. Формат по умолчанию — `json`; другое значение `format` — 422 валидации. Скачивание в аудит не пишется +(`domain/checklist_csv.py`, чистая функция). + ### Сводная ОСВ Чистая функция от версии (`domain/consolidated_osv.build` + `domain/consolidation_periods.select_sources`), в БД не пишется @@ -728,7 +794,7 @@ email без учёта регистра проверяет только сер | `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» | | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | -| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр | +| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (`PATCH …/statement`) — не из 10 или 12 цифр | | `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | @@ -756,6 +822,15 @@ email без учёта регистра проверяет только сер | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | | `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | | `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | +| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) | +| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | +| `UNKNOWN_CELL` | 422 | `PATCH …/statement`: `line_id`/`column_id` не принадлежат этой форме | +| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | +| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | +| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | +| `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` | +| `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id | +| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | @@ -918,6 +993,7 @@ make migration-cycle # проверить цикл upgr api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16) + rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) @@ -932,13 +1008,16 @@ api/ repositories/ доступ к данным schemas/ pydantic-схемы запросов и ответов services/ бизнес-логика; primary_files (загрузка/удаление), result_mapping (цикл маппинга и автозамена), - file_status (статус файла по заданию), documents (карточки, подтверждение), recognition_exchange (обмен); + file_status (статус файла по заданию), documents (карточки, подтверждение, комментарии), statements (форма ББ/ОФР: чтение, правка, акцепт; Срез 3b), + recognition_exchange (обмен); Срез 4 — consolidations (версии, периметр), consolidation_guard (общий пролог правок), consolidation_locks, consolidation_status (`transition`), consolidation_documents (привязка), consolidated_osv (свод, кандидаты), counterparty_mapping (ручное сопоставление), osv_export (xlsx) common/ доменные ошибки (`{detail: {code, message}}`), часы domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn, file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3); + Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (проверки §7), + checklist_csv (CSV чек-листа); Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy (`ensure_version_editable`, §10) tests/ pytest (юнит и integration/fixtures по маркерам) diff --git a/openspec/changes/slice-3b-statements-api/tasks.md b/openspec/changes/slice-3b-statements-api/tasks.md index f58a0c8..795d042 100644 --- a/openspec/changes/slice-3b-statements-api/tasks.md +++ b/openspec/changes/slice-3b-statements-api/tasks.md @@ -1,6 +1,21 @@ # Tasks: Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ -Заполняется планом (`writing-plans`) после одобрения спеки владельцем. Репозиторий — arm-core; команды — из корня -(`make`), `pytest` — из `api/`. +План реализации — `docs/superpowers/plans/2026-10-07-slice-3b-statements-api.md` (16 задач). Репозиторий — arm-core; команды — +из корня (`make`), `pytest` — из `api/`. -- [ ] 0.1 План реализации написан и одобрен; задачи перенесены сюда из плана +- [x] 0 Подготовка окружения (план одобрен, задачи перенесены сюда) +- [x] 1 Проверки §7 — словарь `rules/statement_checks.yaml` и чистые функции `domain/statement_checks.py` +- [x] 2 Чистые функции маппинга формы (`domain/statement_mapping.py`): период, черновик, статус +- [x] 3 Помощники golden-тестов форм +- [x] 4 Маппинг форм ББ/ОФР в `map_file` (`repositories/statements.py`): шапка, колонки, строки, значения, статус +- [x] 5 API формы — `GET /documents/{id}/statement` +- [x] 6 Правка формы (`PATCH …/statement`) и акцепт проверок (`POST …/statement/checks/accept`), аудит `RECOGNIZED_DATA_EDIT` +- [x] 7 Подтверждение формы (`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`) и сквозной сценарий чек-листа +- [x] 8 Комментарии к документу (`GET/POST /documents/{id}/comments`, `comments_count`) +- [x] 9 Отдача исходного файла потоком (`GET /files/{id}/content`) +- [x] 10 Справочник имён пользователей (`GET /users/brief`) +- [x] 11 `documents_count` у ЮЛ +- [x] 12 Чек-лист в CSV (`GET …/checklist?format=csv`) +- [x] 13 README, CLAUDE.md, tasks.md +- [ ] 14 Документы workspace (bounded-PR в docs): §7/§9, контракт `dev/api-contract-slice-3b.md`, OQ — PR docs +- [ ] 15 Завершение: слияние `origin/main`, `make check-full`, `openspec validate --strict`, финальное ревью ветки, PR arm-core -- 2.47.2 From 77b76c076dc237d6e18b5afa93f4ea10120fcb98 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:31:50 +0400 Subject: [PATCH 22/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=E2=84=9619=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=203b;=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20xls?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 9413393..a1927d7 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -109,7 +109,7 @@ comment`, пересчитывает статус, `audit_event(RECOGNIZED_DATA_ файла из S3 частями (без чтения целиком в память); `Content-Type` по `file_type` (`application/pdf`, `image/png`, `image/jpeg`, xls/xlsx/xlsb — свои MIME); `Content-Disposition` — ASCII-имя `file.` и `filename*` RFC 5987 с исходным именем (как экспорт сводной ОСВ); работает и для `SUPERSEDED`; объект истёк или пропал — 404 -`FILE_CONTENT_NOT_FOUND`. Аудит не пишется (как экспорт xlsx). Прямая ссылка на S3 отвергнута: в контуре фронт может +`FILE_CONTENT_NOT_FOUND`. Аудит не пишется (в отличие от экспорта сводной ОСВ в xlsx, который пишет `EXPORT`; см. OQ-153). Прямая ссылка на S3 отвергнута: в контуре фронт может не видеть MinIO, а через ядро действует policy. ### D-11. Комментарии к документу @@ -154,7 +154,7 @@ seed-dev` с нуля. 1. OQ-146: формулы §7 и справочники по редакциям `66n` / `fsbu4`. 2. OQ-152 / I-2 из 3a: повтор кода 2410 в OCR — ядро получает уже отфильтрованную строку. -3. Нужен ли аудит скачивания первички (ТЗ 8.2 «выгрузка») — сейчас нет, как у экспорта. +3. Нужен ли аудит скачивания первички (ТЗ 8.2 «выгрузка») — сейчас нет (экспорт сводной ОСВ в xlsx пишет `EXPORT`, первичка и CSV — нет). ## Decisions made without discussion @@ -167,7 +167,7 @@ seed-dev` с нуля. | 5 | 2026-10-07 | Акцепт сбрасывается любой правкой | данные изменились — проверку нужно смотреть заново | лишний повторный акцепт | | 6 | 2026-10-07 | Аудит акцепта — `RECOGNIZED_DATA_EDIT` | нового `audit_action` без DBML v5.17 нет | фильтр журнала по акцепту — по `object_type`/полям | | 7 | 2026-10-07 | Контрагент и уверенность не блокируют подтверждение | паритет с ОСВ; шум OCR в ИНН | подтверждение чужой формы — защищает статус `NEEDS_CHECK` и подсветка | -| 8 | 2026-10-07 | Скачивание файла, CSV, комментарии — без аудита | паритет с экспортом xlsx; `comment` хранит автора | журнал не покажет скачивания (OQ в docs) | +| 8 | 2026-10-07 | Скачивание файла, CSV, комментарии — без аудита | спека явно: аудит не пишется (экспорт xlsx пишет `EXPORT` — осознанное отличие, OQ-153); `comment` хранит автора | журнал не покажет скачивания (OQ в docs) | | 9 | 2026-10-07 | `GET /users/brief` без отдельного права | имена нужны всем экранам; данные — только ФИО и активность | ФИО видны всем аутентифицированным | | 10 | 2026-10-07 | Нет теста идентичности словаря §7 с tabular-parser | урок #33: межрепозиторные тесты ломают main | копии разойдутся незаметно | | 11 | 2026-10-07 | R-1: признак «повтор формы в файле» при маппинге хранится множеством типов форм и передаётся в `statement_status` как предупреждение; первый документ типа — `NEEDS_CHECK` | поведение по спеке («Повтор формы в файле»); фрагмент плана с разбором строк предупреждений — иллюстрация | — | @@ -178,3 +178,4 @@ seed-dev` с нуля. | 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 | | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | | 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей | +| 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула | -- 2.47.2 From e45502b2eaab7dd968cdd4edece5b179b0757ae5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:35:18 +0400 Subject: [PATCH 23/36] =?UTF-8?q?fix(files):=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BE=D1=81=D0=B2=D0=BE=D0=B1=D0=BE=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20S3-=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20(T9.1,=20T9.2,=20T9.4?= =?UTF-8?q?,=20T9.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClosingStreamingResponse закрывает body_iterator в finally (под shield), ChainedStream закрывает исходный поток и до начала чтения; заголовок X-Content-Type-Options: nosniff; - ASCII-имя в Content-Disposition строится из file_type, а не из расширения исходного имени; - ObjectStorage.stream типизирован как AsyncGenerator, убраны два type: ignore. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/files/read.py | 5 ++- api/src/common/streaming.py | 50 +++++++++++++++++++++++++ api/src/core/clients.py | 4 +- api/src/core/storage.py | 4 +- api/src/services/primary_files.py | 27 ++++++-------- api/tests/conftest.py | 4 +- api/tests/test_api_files_content.py | 58 ++++++++++++++++++++++++++++- 7 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 api/src/common/streaming.py diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index a2b1ba4..236a7c3 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep +from src.common.streaming import ClosingStreamingResponse from src.schemas.common import ErrorDetail, Page, PageParams from src.schemas.files import FileOut @@ -95,7 +96,9 @@ async def get_file_content( ) -> StreamingResponse: """Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере.""" stream, media_type, header = await service.content(file_id, disposition) - return StreamingResponse(stream, media_type=media_type, headers={"Content-Disposition": header}) + return ClosingStreamingResponse( + stream, media_type=media_type, headers={"Content-Disposition": header, "X-Content-Type-Options": "nosniff"} + ) @router.delete( diff --git a/api/src/common/streaming.py b/api/src/common/streaming.py new file mode 100644 index 0000000..119f222 --- /dev/null +++ b/api/src/common/streaming.py @@ -0,0 +1,50 @@ +"""Потоковая отдача файла с детерминированным освобождением источника (T9.1, Review Focus 4 плана Среза 3b). + +Starlette `StreamingResponse.stream_response` не вызывает `aclose()` у `body_iterator`: при обрыве клиента или если тело +не начали итерировать поток S3 освобождался бы только сборщиком мусора. `ClosingStreamingResponse` закрывает итератор +в `finally`, а `ChainedStream` гарантирует, что `aclose` закрывает и исходный поток, даже если обёртку не начинали +читать (у неначатого асинхронного генератора `finally` не выполняется, поэтому обёртка — объект, а не генератор). +""" + +from collections.abc import AsyncGenerator + +import anyio +from fastapi.responses import StreamingResponse + + +class ChainedStream: + """Уже прочитанная первая порция и остальной поток `rest`; `aclose` всегда закрывает `rest` (там `close` + `release` + ответа S3), в том числе при завершении, ошибке и обрыве потребителя, начатом и не начатом чтении.""" + + def __init__(self, first: bytes, rest: AsyncGenerator[bytes, None]) -> None: + self._first = first + self._rest = rest + + def __aiter__(self) -> "ChainedStream": + return self + + async def __anext__(self) -> bytes: + if self._first: + chunk, self._first = self._first, b"" + return chunk + return await anext(self._rest) + + async def aclose(self) -> None: + """Закрыть исходный поток (идемпотентно: у завершённого генератора `aclose` — no-op).""" + await self._rest.aclose() + + +class ClosingStreamingResponse(StreamingResponse): + """`StreamingResponse`, закрывающий `body_iterator` (`aclose`) после отдачи, ошибки или обрыва клиента. + + Закрытие идёт под `CancelScope(shield=True)`: при обрыве клиента задача отдачи отменяется, а освобождение + соединения S3 требует `await`.""" + + async def stream_response(self, send) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + try: + await super().stream_response(send) + finally: + aclose = getattr(self.body_iterator, "aclose", None) + if aclose is not None: + with anyio.CancelScope(shield=True): + await aclose() diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 0d2860f..097d5b6 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -2,7 +2,7 @@ по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). """ -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from io import BytesIO from miniopy_async import Minio @@ -47,7 +47,7 @@ class S3Storage: response.close() await response.release() - async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncGenerator[bytes, None]: """Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве.""" try: response = await self.client.get_object(self.bucket, obj_name) diff --git a/api/src/core/storage.py b/api/src/core/storage.py index cdfb87a..9eaf8e4 100644 --- a/api/src/core/storage.py +++ b/api/src/core/storage.py @@ -4,7 +4,7 @@ `services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12). """ -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from typing import Protocol @@ -23,7 +23,7 @@ class ObjectStorage(Protocol): """Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`.""" ... - def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncGenerator[bytes, None]: """Объект порциями по `chunk_size` байт; отсутствие объекта — `ObjectNotFoundError` до первой порции. Закрытие итератора (`aclose`) освобождает соединение с S3.""" diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index e5be153..7d29469 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -18,7 +18,7 @@ audit-trail «События файлов и документов»; design D-4) import hashlib import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator from dataclasses import dataclass from typing import Literal @@ -30,7 +30,8 @@ from starlette import status as http_status from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError -from src.common.filenames import CONTENT_TYPES, content_disposition, extension, safe_filename +from src.common.filenames import CONTENT_TYPES, content_disposition, safe_filename +from src.common.streaming import ChainedStream from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage @@ -116,16 +117,10 @@ class RejectedFile: ) -async def _chain(first: bytes, rest: AsyncIterator[bytes]) -> AsyncIterator[bytes]: - """Первая (уже прочитанная) порция и остальной поток; при завершении, ошибке и обрыве потребителя поток S3 - закрывается (`aclose` — там `close` + `release` ответа), соединение не остаётся занятым.""" - try: - if first: - yield first - async for chunk in rest: - yield chunk - finally: - await rest.aclose() # type: ignore[attr-defined] +def _chain(first: bytes, rest: AsyncGenerator[bytes, None]) -> ChainedStream: + """Первая (уже прочитанная) порция и остальной поток; `aclose` закрывает поток S3 (там `close` + `release` + ответа) — соединение не остаётся занятым при завершении, ошибке и обрыве потребителя (в т. ч. не начатом).""" + return ChainedStream(first, rest) def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str: @@ -255,7 +250,7 @@ class PrimaryFilesService: async def content( self, file_id: uuid.UUID, disposition: Literal["attachment", "inline"] = "attachment" - ) -> tuple[AsyncIterator[bytes], str, str]: + ) -> tuple[ChainedStream, str, str]: """Исходный файл потоком: `(поток, Content-Type, Content-Disposition)` (spec documents-api, D-10). Файл любого статуса (в т. ч. `SUPERSEDED`) в области видимости; чужой и несуществующий — 404. Ключ и имя @@ -275,11 +270,11 @@ class PrimaryFilesService: except ObjectNotFoundError: raise FileContentNotFoundError() from None except BaseException: - await iterator.aclose() # type: ignore[attr-defined] + await iterator.aclose() raise name = file.original_name or "" - suffix = extension(name) or file.file_type.value.lower() - header = content_disposition(name or f"file.{suffix}", disposition=disposition, ascii_fallback=f"file.{suffix}") + ascii_name = f"file.{file.file_type.value.lower()}" + header = content_disposition(name or ascii_name, disposition=disposition, ascii_fallback=ascii_name) media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") return _chain(first, iterator), media_type, header diff --git a/api/tests/conftest.py b/api/tests/conftest.py index b1caa39..76c72d9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -13,7 +13,7 @@ S3 — `FakeStorage` (объекты в словаре). """ import uuid -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from functools import partial from pathlib import Path @@ -222,7 +222,7 @@ class FakeStorage: except KeyError: raise ObjectNotFoundError(obj_name) from None - async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncGenerator[bytes, None]: try: data = self.objects[obj_name] except KeyError: diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py index f4e9256..06b05e6 100644 --- a/api/tests/test_api_files_content.py +++ b/api/tests/test_api_files_content.py @@ -1,12 +1,13 @@ """Исходный файл потоком (spec documents-api «Исходный файл»; design D-10).""" -from collections.abc import AsyncIterator +from collections.abc import AsyncGenerator import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.common.streaming import ClosingStreamingResponse from src.core.storage import ObjectNotFoundError from src.models.enums import FileStatus, FileType, RecognitionStatus from src.models.files import S3Object, StoredFile @@ -20,6 +21,7 @@ PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка п HTTP_OK = 200 HTTP_NOT_FOUND = 404 HTTP_FORBIDDEN = 403 +SECOND_BODY = 3 async def _file( @@ -46,6 +48,7 @@ async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFact assert response.headers["content-type"] == "application/pdf" assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" assert response.content == PDF + assert response.headers["x-content-type-options"] == "nosniff" # T9.5 async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 @@ -136,7 +139,7 @@ class _Source: self._fail_after = fail_after self.iterator = self._run() - async def _run(self) -> AsyncIterator[bytes]: + async def _run(self) -> AsyncGenerator[bytes, None]: try: for index in range(3): if self._fail_after == index: @@ -165,3 +168,54 @@ async def test_chain_closes_source_on_error_and_yields_all() -> None: complete = _Source() assert b"".join([chunk async for chunk in _chain(await anext(complete.iterator, b""), complete.iterator)]) == b"xxx" assert complete.closed + + +async def test_chain_aclose_closes_source_when_never_iterated() -> None: + """T9.1: поток не начали отдавать (первая порция уже прочитана) — `aclose` всё равно закрывает S3-поток.""" + source = _Source() + chain = _chain(await anext(source.iterator, b""), source.iterator) + await chain.aclose() + assert source.closed + + +async def test_response_closes_stream_when_client_drops_after_first_chunk() -> None: + """T9.1: `send` падает на втором теле (обрыв клиента) — `body_iterator` закрыт, S3-поток освобождён.""" + source = _Source() + chain = _chain(await anext(source.iterator, b""), source.iterator) + response = ClosingStreamingResponse(chain, media_type="application/pdf") + sent: list[str] = [] + + async def send(message: dict) -> None: # type: ignore[type-arg] + sent.append(message["type"]) + if len(sent) == SECOND_BODY: # start, первая порция, вторая порция + raise OSError("клиент отключился") + + with pytest.raises(OSError, match="клиент"): + await response.stream_response(send) + assert source.closed + + +async def test_response_closes_stream_when_send_fails_at_start() -> None: + """T9.1: тело не начали итерировать (падает уже отправка заголовков) — поток тоже закрыт.""" + source = _Source() + chain = _chain(await anext(source.iterator, b""), source.iterator) + response = ClosingStreamingResponse(chain) + + async def send(message: dict) -> None: # type: ignore[type-arg] + raise OSError("клиент отключился") + + with pytest.raises(OSError, match="клиент"): + await response.stream_response(send) + assert source.closed + + +async def test_ascii_extension_from_file_type(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + """T9.2: ASCII-имя берётся из `file_type`, а не из расширения исходного имени (нестандартное/кириллическое).""" + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "отчёт.экс", FileType.XLSX) + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment; filename=\"file.xlsx\"; filename*=UTF-8''") + assert disposition.isascii() -- 2.47.2 From fb457fc06b140b79033c4d02e0841934fa74df8a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:37:01 +0400 Subject: [PATCH 24/36] =?UTF-8?q?fix(statements):=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20units=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D0=B8=20comment=20=D0=B2=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D0=B5=20=D1=81=D0=B1=D1=80=D0=BE=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B0=D0=BA=D1=86=D0=B5=D0=BF=D1=82=D0=B0=20(T6.2,=20T6.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HeaderPatch.units: обрезка пробелов и min_length=1, пробельная строка — 422; null — «не менять» (docstring); - запись аудита сброса акцепта содержит comment сброшенного акцепта. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/schemas/statements.py | 10 +++++++--- api/src/services/statements.py | 6 +++++- api/tests/test_api_statements.py | 25 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index e2d0a56..c546d2a 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -9,8 +9,9 @@ import datetime import uuid from collections.abc import Sequence from decimal import Decimal +from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, StringConstraints from src.domain.statement_checks import ChecksReport, FailedCheck from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter @@ -237,13 +238,16 @@ class CellPatch(BaseModel): class HeaderPatch(BaseModel): - """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`).""" + """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`). + + `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 + символов (пробельная строка — 422).""" model_config = ConfigDict(extra="forbid") inn: str | None = None report_date: datetime.date | None = None - units: str | None = Field(None, min_length=1, max_length=50) + units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None class StatementPatch(BaseModel): diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 37c59c3..9812950 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -202,7 +202,11 @@ class StatementService: if not after: return await self.build_out(document, meta) if meta.checks_accepted_at is not None: - before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at} + before["checks_accepted"] = { + "by": meta.checks_accepted_by, + "at": meta.checks_accepted_at, + "comment": meta.checks_accepted_comment, + } after["checks_accepted"] = None meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None await self._session.flush() diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 5a6d69e..1d9ba08 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -410,6 +410,7 @@ async def test_patch_rereads_meta_under_lock( assert held.checks_accepted_at is None and held.checks_accepted_comment is None event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one() assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None + assert event.before_value["checks_accepted"]["comment"] == "x" # T6.4: сброшенный комментарий акцепта сохранён async def test_patch_duplicate_cell_rejected( @@ -713,3 +714,27 @@ async def test_confirmed_form_in_checklist( ) ).scalar_one() assert meta.reviewed_by == user.id + + +@pytest.mark.parametrize("units", [" ", "", "я" * 51]) +async def test_patch_units_invalid_is_422( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, units: str +) -> None: + """T6.2: пробельная, пустая и длиннее 50 символов единицы измерения отклоняются; `null` — «не менять».""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + response = await client.patch(url, json={"header": {"units": units}}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text + unchanged = await client.patch(url, json={"header": {"units": None}}) + assert unchanged.status_code == HTTPStatus.OK + + +async def test_patch_units_are_stripped( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": " тыс. руб. "}}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["header"]["units"]["value"] == "тыс. руб." -- 2.47.2 From d8073800689478197065ab9d201f03acdf881c66 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:39:37 +0400 Subject: [PATCH 25/36] =?UTF-8?q?refactor(directories,consolidations):=20d?= =?UTF-8?q?ocuments=5Fcount=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D1=85?= =?UTF-8?q?=D0=B5=D0=BB=D0=BF=D0=B5=D1=80=D0=BE=D0=BC=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D0=B5=20CSV=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20(T11.1,=20T11.2,=20T12.?= =?UTF-8?q?2,=20T12.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ЮЛ: service.documents_count(entity_id), _out с типом LegalEntity и обязательным documents_count; - чек-лист: в OpenAPI описан ответ text/csv, BOM — константа, elif после return убраны, тест на полноту подписей CellStatus и на байты BOM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/consolidations/checklist.py | 13 +++++++++---- .../api/endpoints/directories/legal_entities.py | 15 ++++++--------- api/src/domain/checklist_csv.py | 14 +++++++------- api/src/services/legal_entities.py | 4 ++++ api/tests/test_api_legal_entities.py | 4 ++++ api/tests/test_domain_checklist_csv.py | 9 +++++++-- api/tests/test_openapi_routes.py | 9 +++++++++ 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py index 474f178..9293e3a 100644 --- a/api/src/api/endpoints/consolidations/checklist.py +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -16,8 +16,13 @@ from src.schemas.consolidations import ChecklistOut router = APIRouter(prefix="/consolidations/versions") VIEW = [Depends(require("CONSOLIDATION_VIEW"))] -NOT_FOUND: dict[int | str, dict[str, Any]] = { - status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +CSV_MEDIA_TYPE = "text/csv" +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: { + "description": "чек-лист: JSON или (при `format=csv`) файл CSV — UTF-8 с BOM, разделитель `;`, CRLF", + "content": {CSV_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}, + }, + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, } @@ -25,7 +30,7 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = { "/{version_id}/checklist", response_model=ChecklistOut, dependencies=VIEW, - responses=NOT_FOUND, + responses=RESPONSES, summary="Чек-лист комплекта документов версии", ) async def get_checklist( @@ -45,7 +50,7 @@ async def get_checklist( csv_content = render_checklist_csv(checklist) return Response( content=csv_content, - media_type="text/csv; charset=utf-8", + media_type=f"{CSV_MEDIA_TYPE}; charset=utf-8", headers={ "Content-Disposition": content_disposition( filename, disposition="attachment", ascii_fallback="checklist.csv" diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index 3a929c5..eda1317 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Query, status from src.api.dependencies.auth import require from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep +from src.models.directories import LegalEntity from src.schemas.common import Page, PageParams from src.schemas.directories import ( AliasCreate, @@ -36,7 +37,7 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams): """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" -def _out(entity, documents_count: int = 0) -> LegalEntityOut: +def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut: """Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов.""" out = LegalEntityOut.model_validate(entity) out.documents_count = documents_count @@ -75,8 +76,7 @@ async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServi async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail: """ЮЛ с текущими членствами ГСК/ГСЗ и алиасами.""" detail = await service.get(entity_id) - counts = await service.documents_count_by_entities([entity_id]) - detail.documents_count = counts.get(entity_id, 0) + detail.documents_count = await service.documents_count(entity_id) return detail @@ -86,8 +86,7 @@ async def update_legal_entity( ) -> LegalEntityOut: """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" entity = await service.update(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @@ -96,8 +95,7 @@ async def move_legal_entity( ) -> LegalEntityOut: """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" entity = await service.move(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.post( @@ -109,8 +107,7 @@ async def withdraw_legal_entity( """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства закрываются.""" entity = await service.withdraw(entity_id, data) - counts = await service.documents_count_by_entities([entity_id]) - return _out(entity, counts.get(entity_id, 0)) + return _out(entity, await service.documents_count(entity_id)) @router.get( diff --git a/api/src/domain/checklist_csv.py b/api/src/domain/checklist_csv.py index 487a910..0f33f86 100644 --- a/api/src/domain/checklist_csv.py +++ b/api/src/domain/checklist_csv.py @@ -11,6 +11,8 @@ from src.domain.checklist import CellStatus from src.models.enums import DocumentType from src.schemas.consolidations import ChecklistOut +BOM = "\ufeff" # UTF-8 BOM: Excel распознаёт кодировку + # Подписи статусов ячеек (макет UI) _CELL_STATUS_LABELS = { CellStatus.CONFIRMED: "Загружен", @@ -22,17 +24,15 @@ _CELL_STATUS_LABELS = { } -# Подписи правил def _rule_label(document_type: DocumentType, account: str | None) -> str: """Подпись правила для заголовка CSV.""" if document_type == DocumentType.BALANCE: return "Бух. баланс" - elif document_type == DocumentType.INCOME: + if document_type == DocumentType.INCOME: return "ОФР" - elif document_type == DocumentType.OSV: + if document_type == DocumentType.OSV: return f"ОСВ {account}" if account else "ОСВ" - else: - return document_type.value + return document_type.value def render_checklist_csv(checklist: ChecklistOut) -> bytes: @@ -40,7 +40,7 @@ def render_checklist_csv(checklist: ChecklistOut) -> bytes: Заголовок: Организация;ИНН;<правило>… Строки: имя ЮЛ, ИНН, ячейки по порядку правил. - Статус ячейки: подпись из CELL_STATUS_LABELS, с " (нет: Q1, Q2)" при missing_quarters. + Статус ячейки: подпись из `_CELL_STATUS_LABELS`, с " (нет: Q1, Q2)" при missing_quarters. """ buffer = io.StringIO() writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n") @@ -68,7 +68,7 @@ def render_checklist_csv(checklist: ChecklistOut) -> bytes: # Кодировать в UTF-8 с BOM csv_text = buffer.getvalue() - return ("" + csv_text).encode("utf-8") + return (BOM + csv_text).encode("utf-8") def checklist_filename(group_name: str, year: int, quarter: str) -> str: diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 6247b63..ffaa497 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -279,6 +279,10 @@ class LegalEntityService: """Число документов для каждой ЮЛ (файлы со статусом не SUPERSEDED).""" return await repo.documents_count_by_entity(self._session, entity_ids) + async def documents_count(self, entity_id: uuid.UUID) -> int: + """Число документов одной ЮЛ (как `documents_count_by_entities`).""" + return (await self.documents_count_by_entities([entity_id])).get(entity_id, 0) + async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity: entity = await repo.get_legal_entity(self._session, self._scope, entity_id) if entity is None: diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 8ce5a1b..1de818d 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -614,3 +614,7 @@ async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFact card = (await client.get(f"/directories/legal-entities/{entity.id}")).json() assert card["documents_count"] == expected_count + + patched = await client.patch(f"/directories/legal-entities/{entity.id}", json={"name": "ООО Счётчик плюс"}) + assert patched.status_code == HTTP_OK, patched.text + assert patched.json()["documents_count"] == expected_count # T11.2: тот же подсчёт в PATCH diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index d5c554a..6adef73 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -3,7 +3,7 @@ import uuid from src.domain.checklist import CellStatus -from src.domain.checklist_csv import checklist_filename, render_checklist_csv +from src.domain.checklist_csv import _CELL_STATUS_LABELS, checklist_filename, render_checklist_csv from src.models.enums import DocumentType, PeriodQuarter from src.schemas.consolidations import ChecklistCellOut, ChecklistOut, ChecklistRowOut, ChecklistRuleOut @@ -27,7 +27,7 @@ def test_render() -> None: ], ) content = render_checklist_csv(checklist) - assert content.startswith("".encode()) + assert content.startswith(b"\xef\xbb\xbf") # UTF-8 BOM lines = content.decode("utf-8-sig").splitlines() assert lines[0] == "Организация;ИНН;Бух. баланс;ОСВ 60" assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' @@ -35,3 +35,8 @@ def test_render() -> None: def test_filename() -> None: assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" + + +def test_every_cell_status_has_label() -> None: + """Запасное значение статуса (`cell.status.value`) — безопасный фолбэк, но таблица подписей полна.""" + assert set(_CELL_STATUS_LABELS) == set(CellStatus) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 8d426bb..90d2758 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -182,6 +182,15 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) assert not {"limit", "offset"} & names +def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None: + """T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422.""" + checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"] + assert {"200", "404", "422"} <= set(checklist["responses"]) + content = checklist["responses"]["200"]["content"] + assert content["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") + assert content["text/csv"]["schema"] == {"type": "string", "format": "binary"} + + def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: """Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422.""" lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"] -- 2.47.2 From 7578b5fb1299e8548d1aee002ba7c256eeaa197d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 13:44:01 +0400 Subject: [PATCH 26/36] =?UTF-8?q?docs(arm-core):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20README=20=D0=B8=20CLAUDE.md=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=2013=20(I1=E2=80=93I3,?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=BD=D0=BE=D1=80=D0=BD=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 7 +++-- README.md | 29 ++++++++++++------- .../changes/slice-3b-statements-api/tasks.md | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e26fdc6..7c0f9ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,8 @@ (OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится. - **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать); - документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409 + второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ + без данных на тип (`RECOGNIZED`, D-3); документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — `domain/statement_checks.py` по словарю `api/rules/statement_checks.yaml`; **файл — копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок). Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт — @@ -100,7 +101,9 @@ не блокируют. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`, без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` + `ObjectStorage.stream` (поток из S3, первая порция до заголовков → `FILE_CONTENT_NOT_FOUND`; сессию тело потока не держит; - имя — RFC 5987). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — + имя — RFC 5987, `X-Content-Type-Options: nosniff`; освобождение потока — `common/streaming.py`: `ClosingStreamingResponse` + + `ChainedStream` закрывают источник и при обрыве клиента). Скачивание первички, CSV и комментарии аудит не пишут (в отличие от + экспорта сводной ОСВ в xlsx — тот пишет `EXPORT`; OQ-153). `HeaderPatch.units` — непустая строка после обрезки (пробельная — 422). `GET /users/brief` — `current_user` без права; `documents_count` у ЮЛ — `repositories/directories`; CSV чек-листа — `?format=csv` у `GET …/checklist`. Контракт для фронта — `../docs/dev/api-contract-slice-3b.md`; решения — `openspec/changes/slice-3b-statements-api/design.md`. - **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export, diff --git a/README.md b/README.md index 2275c76..153004a 100644 --- a/README.md +++ b/README.md @@ -461,14 +461,17 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN `PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`. -**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ: +**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на +тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, сразу `RECOGNIZED`). Условия для листа ОСВ: - `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу (`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет; - `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта в файле, строки без родителя, нарастающая по данным Q1; +- для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо + проверок парсера; - `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит); -- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (файл без разбираемой ОСВ → файл `ERROR`). +- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; `period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. @@ -485,7 +488,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл отвергаются); 2. читает объект из S3 **вне транзакции** и валидирует `RecognitionResult` v1.x (`job_id`/`file_id` должны совпасть с заданием); 3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт - `document` на каждый лист ОСВ и на каждую форму ББ/ОФР (шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`, + `document` на каждый лист ОСВ и на каждую форму ББ/ОФР (второй `BALANCE`/`INCOME` файла пропускается с предупреждением; шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`, `account_id`, `counterparty_id`), пишет `recognition_job.group_schema` и `metrics.mapping_warnings`, переводит файл в `PROCESSED` и выполняет автозамену (см. «Удаление и замена»). @@ -522,10 +525,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) | | `GET /documents/{document_id}/comments` | `DOCUMENT_VIEW` | комментарии по возрастанию `created_at`: `{id, text, author {id, full_name}, created_at}` | | `POST /documents/{document_id}/comments` | `DOCUMENT_EDIT` | добавить комментарий (`{text}`, 1–4000 символов после обрезки пробелов), 201; в любом статусе документа | -| `GET /files/{file_id}/content` | `DOCUMENT_VIEW` | исходный файл потоком из S3; `?disposition=attachment` (по умолчанию) или `inline`; имя — `Content-Disposition` по RFC 5987 | +| `GET /files/{file_id}/content` | `DOCUMENT_VIEW` | исходный файл потоком из S3; `?disposition=attachment` (по умолчанию) или `inline`; имя — `Content-Disposition` по RFC 5987, `X-Content-Type-Options: nosniff` | Карточка документа и документ в карточке файла несут `comments_count`. Просмотр и скачивание первички (`/files/{id}/content`) -и CSV чек-листа в аудит не пишутся (как экспорт сводной ОСВ). Чужой и несуществующий файл — 404 `NOT_FOUND`; файл есть, а объекта +и CSV чек-листа, как и комментарии, в аудит не пишутся (в отличие от экспорта сводной ОСВ в xlsx: он пишет `EXPORT`; OQ-153). Тело потока из S3 освобождается детерминированно (`common/streaming.py`: `ClosingStreamingResponse`, `ChainedStream`), в том числе при обрыве клиента. Чужой и несуществующий файл — 404 `NOT_FOUND`; файл есть, а объекта в S3 нет (истёк или пропал) — 404 `FILE_CONTENT_NOT_FOUND`; S3 не подключено — 503 `STORAGE_UNAVAILABLE`. Страницы — `{items, total, limit, offset}`. Суммы строк — десятичные значения без округления, как в результате парсера. @@ -539,7 +542,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл ## Формы ББ/ОФР (Срез 3b) Цикл маппинга разбирает не только листы ОСВ, но и формы ББ/ОФР результата парсера: на каждую форму создаются `document` -(`BALANCE`/`INCOME`) и шапка `financial_statement_meta` с колонками, строками и значениями (`financial_statement_column`, +(`BALANCE`/`INCOME`; второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением маппинга, OQ-77, D-3, а первый получает +`NEEDS_CHECK`) и шапка `financial_statement_meta` с колонками, строками и значениями (`financial_statement_column`, `_line`, `_value`). Первичка не мутируется: правка аналитика пишется рядом (`is_modified`, `original_amount`, `modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений @@ -549,13 +553,14 @@ S3 (storage); без S3 — предупреждение в логе, цикл в обоих репозиториях). - **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`, - `report_date`, `units` вида `{value, original_value, verified, modified}` (у ИНН ещё `confidence`) и справочные поля формы + `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы (`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `checks` — `{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`. Подтверждённая форма читается. - **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` - (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков). Правится только неподтверждённая форма + (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, + пробельная строка — 422, `null` — «не менять»). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно @@ -563,7 +568,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус. Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`. - **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок - (`checks_accepted_by/at/comment`). Нарушений нет — 409 `NOTHING_TO_ACCEPT`. Акцепт сбрасывается любой правкой формы. + (`checks_accepted_by/at/comment`). Нарушений нет — 409 `NOTHING_TO_ACCEPT`. Акцепт сбрасывается любой правкой формы (в `before_value` события аудита сброса остаётся комментарий сброшенного акцепта). - **Подтверждение формы** (`POST /documents/{id}/confirm`) дополнительно требует определённый период (иначе 409 `PERIOD_REQUIRED`) и пройденные или акцептованные жёсткие проверки §7 (иначе 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` — `{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`) @@ -599,7 +604,9 @@ S3 (storage); без S3 — предупреждение в логе, цикл - Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены (Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы. - Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). - Результаты `scan-gateway` (PDF/изображения) и документы `OTHER` по-прежнему игнорируются без предупреждения. + Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED`, + `locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается + любой результат, проходящий схему `recognition-result`. - Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД» получают `account_root = NULL`; `account_id` ищется точным совпадением с планом счетов seed, субсчетов в seed неполный набор — часть строк остаётся с `account_id = NULL`. @@ -816,7 +823,7 @@ email без учёта регистра проверяет только сер | `UNSUPPORTED_FILE` | 422 | загрузка: тип не из xls/xlsx/xlsb/pdf/png/jpg, расширение не совпало с сигнатурой, пустое имя; строка `rejected[]` ответа загрузки (при импорте — файл целиком) | | `FILE_TOO_LARGE` | 422 | загрузка: файл больше `UPLOAD_MAX_BYTES`; строка `rejected[]` | | `DUPLICATE_FILE` | 422 | загрузка: тот же sha256 у файла этого ЮЛ (не `SUPERSEDED`); строка `rejected[]` с `file_id` прежнего файла | -| `STORAGE_UNAVAILABLE` | 503 / 422 | S3 не подключено — 503 на весь запрос; сбой записи одного файла — строка `rejected[]` (если отклонены все — 422) | +| `STORAGE_UNAVAILABLE` | 503 / 422 | S3 не подключено — 503 на весь запрос (и на `GET /files/{id}/content`); сбой записи одного файла — строка `rejected[]` (если отклонены все — 422) | | `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` | | `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией | | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | diff --git a/openspec/changes/slice-3b-statements-api/tasks.md b/openspec/changes/slice-3b-statements-api/tasks.md index 795d042..6b384eb 100644 --- a/openspec/changes/slice-3b-statements-api/tasks.md +++ b/openspec/changes/slice-3b-statements-api/tasks.md @@ -17,5 +17,5 @@ - [x] 11 `documents_count` у ЮЛ - [x] 12 Чек-лист в CSV (`GET …/checklist?format=csv`) - [x] 13 README, CLAUDE.md, tasks.md -- [ ] 14 Документы workspace (bounded-PR в docs): §7/§9, контракт `dev/api-contract-slice-3b.md`, OQ — PR docs +- [x] 14 Документы workspace (bounded-PR в docs): §7/§9, контракт `dev/api-contract-slice-3b.md`, OQ — PR docs arm-ka-docs#49 создан, ждёт ревью/слияния - [ ] 15 Завершение: слияние `origin/main`, `make check-full`, `openspec validate --strict`, финальное ревью ветки, PR arm-core -- 2.47.2 From 308fbc4fb430f988fedafd183f320cb77b314e20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:00:09 +0400 Subject: [PATCH 27/36] =?UTF-8?q?fix(files):=20=D1=82=D0=B8=D0=BF=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20send=20=D0=B8=20=D1=81=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D1=81=20=D0=BF=D1=80=D0=B5=D1=84=D0=B5=D1=82=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D1=80=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B2=20ChainedStream.aclose=20(PB.1,=20PB.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/common/streaming.py | 7 +++++-- api/tests/test_api_files_content.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/src/common/streaming.py b/api/src/common/streaming.py index 119f222..93e0edc 100644 --- a/api/src/common/streaming.py +++ b/api/src/common/streaming.py @@ -10,6 +10,7 @@ from collections.abc import AsyncGenerator import anyio from fastapi.responses import StreamingResponse +from starlette.types import Send class ChainedStream: @@ -30,7 +31,9 @@ class ChainedStream: return await anext(self._rest) async def aclose(self) -> None: - """Закрыть исходный поток (идемпотентно: у завершённого генератора `aclose` — no-op).""" + """Закрыть исходный поток и сбросить префетченную порцию: после закрытия `__anext__` её не отдаёт + (идемпотентно: у завершённого генератора `aclose` — no-op).""" + self._first = b"" await self._rest.aclose() @@ -40,7 +43,7 @@ class ClosingStreamingResponse(StreamingResponse): Закрытие идёт под `CancelScope(shield=True)`: при обрыве клиента задача отдачи отменяется, а освобождение соединения S3 требует `await`.""" - async def stream_response(self, send) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + async def stream_response(self, send: Send) -> None: try: await super().stream_response(send) finally: diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py index 06b05e6..40b6eea 100644 --- a/api/tests/test_api_files_content.py +++ b/api/tests/test_api_files_content.py @@ -178,6 +178,19 @@ async def test_chain_aclose_closes_source_when_never_iterated() -> None: assert source.closed +async def test_chain_does_not_yield_prefetched_chunk_after_aclose() -> None: + """PB.2: закрытая обёртка не отдаёт префетченную порцию (иначе потребитель читал бы данные закрытого потока).""" + source = _Source() + first = await anext(source.iterator, b"") + assert first == b"x" + chain = _chain(first, source.iterator) + await chain.aclose() + assert source.closed + with pytest.raises(StopAsyncIteration): + await chain.__anext__() + await chain.aclose() # повторное закрытие безопасно + + async def test_response_closes_stream_when_client_drops_after_first_chunk() -> None: """T9.1: `send` падает на втором теле (обрыв клиента) — `body_iterator` закрыт, S3-поток освобождён.""" source = _Source() -- 2.47.2 From 234e8304760ed2576a6f725fdf8c915dbdb3579d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:00:09 +0400 Subject: [PATCH 28/36] =?UTF-8?q?test(statements):=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8B=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20minor-=D0=BD=D0=B0=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=201-12=20(T1.1,=20T2.1,=20T3.1,=20T3.3,=20T4?= =?UTF-8?q?.x,=20T5.5,=20T7.2,=20T7.3,=20T8.4,=20T10.1,=20T11.3,=20T12.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверки §7 по колонкам, допуск, abs_terms, M-9, граница округления; граничные случаи маппинга колонок и уверенности; шапка, смешанные снимки, статус при задании needs_check, предупреждение о повторе формы и идемпотентный повторный маппинг форм; редакция формы, пара ББ/ОФР, 403 без права, подтверждение формы без шапки и без дат; комментарий без видимости ЮЛ; справочник пользователей; documents_count; CSV чек-листа. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/fixtures_helpers.py | 13 +- api/tests/test_api_comments.py | 14 ++ api/tests/test_api_consolidation_checklist.py | 59 ++++++++ api/tests/test_api_legal_entities.py | 56 ++++++++ api/tests/test_api_statements.py | 133 ++++++++++++++++++ api/tests/test_api_users_brief.py | 49 +++++++ api/tests/test_domain_checklist_csv.py | 48 +++++++ api/tests/test_domain_statement_checks.py | 78 ++++++++++ api/tests/test_domain_statement_mapping.py | 56 ++++++++ api/tests/test_fixtures_marker.py | 27 ++++ api/tests/test_mapping_golden.py | 23 +++ api/tests/test_mapping_statements.py | 109 +++++++++++++- 12 files changed, 657 insertions(+), 8 deletions(-) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 62d0d1d..f42e592 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession if TYPE_CHECKING: from src.contract.result import RecognitionResult from src.models.directories import LegalEntity + from src.models.enums import RecognitionStatus from src.models.files import Document FIXTURES_ENV = "ARM_KA_FIXTURES" @@ -58,9 +59,9 @@ def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Recogni "status": data["status"], "documents": data["documents"], } - if data.get("error"): + if data.get("error") is not None: payload["error"] = data["error"] - if data.get("metrics"): + if data.get("metrics") is not None: payload["metrics"] = data["metrics"] return RecognitionResult.model_validate(payload) @@ -74,20 +75,22 @@ def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Reco return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) -async def map_golden_snapshot( +async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные session: AsyncSession, *, entity: "LegalEntity", path: Path, uploaded_by: uuid.UUID | None, result_builder: Callable[..., "RecognitionResult"] | None = None, + job_status: "RecognitionStatus | None" = None, ) -> "list[Document]": """Golden-снимок `path` → файл ЮЛ `entity` и его документы в БД теста (`flush`, без commit). Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и настоящим `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы до маппинга). Возвращает документы файла отсортированные по (document_type, osv_account_code). - `result_builder` — функция создания RecognitionResult (по умолчанию golden_result). + `result_builder` — функция создания RecognitionResult (по умолчанию golden_result); `job_status` — статус + текущего задания (по умолчанию `done`; `needs_check` — как после результата с `status = needs_check`). """ # ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл) from src.models.accounting import ChartOfAccount # noqa: PLC0415 @@ -101,7 +104,7 @@ async def map_golden_snapshot( session, entity=entity, status=FileStatus.PROCESSING, - job_status=RecognitionStatus.done, + job_status=job_status or RecognitionStatus.done, original_name=path.name, uploaded_by=uploaded_by, ) diff --git a/api/tests/test_api_comments.py b/api/tests/test_api_comments.py index 7bece3d..e6f5e41 100644 --- a/api/tests/test_api_comments.py +++ b/api/tests/test_api_comments.py @@ -65,3 +65,17 @@ async def test_comment_on_superseded_file(seeded: AsyncSession, auth_client: Aut assert created.status_code == HTTP_CREATED, created.text listing = (await client.get(f"/legal-entities/{entity.id}/files", params={"include_superseded": "true"})).json() assert listing["items"][0]["documents"][0]["comments_count"] == 1 + + +async def test_post_by_editor_without_entity_visibility_is_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Право `DOCUMENT_EDIT` есть, но ЮЛ чужого филиала не видно: POST — 404 (как для чужого документа), комментарий не + создаётся.""" + _, document = await _doc(seeded) + url = f"/documents/{document.id}/comments" + stranger = await auth_client("client_manager@armka.local") # DOCUMENT_EDIT, другой филиал + response = await stranger.post(url, json={"text": "чужой комментарий"}) + assert response.status_code == HTTP_NOT_FOUND, response.text + owner = await auth_client(KA_RF) + assert (await owner.get(url)).json() == [] diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index 5924a05..b69ed3e 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -3,6 +3,7 @@ import uuid from typing import Any +from urllib.parse import unquote import pytest from sqlalchemy import select @@ -25,6 +26,7 @@ from tests.factories import ( HTTP_OK = 200 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 KA_RF = "ka_rf@armka.local" CURATOR = "curator_go@armka.local" FOREIGN = "client_manager@armka.local" # Краснодарский филиал @@ -260,3 +262,60 @@ async def test_checklist_csv_format(auth_client: AuthClientFactory, seeded: Asyn disposition = response.headers.get("content-disposition", "") assert "filename*=UTF-8''" in disposition assert "checklist-" in disposition + + +@pytest.mark.integration +async def test_checklist_csv_exact_headers_and_full_row(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """CSV: тип с `charset=utf-8`, имя файла `checklist-<ГК>-<год>-<квартал>.csv` (percent-кодировано в `filename*`, + ASCII-запасной вариант в `filename`) и строка данных целиком — все 10 ячеек по правилам комплекта.""" + version, (first, second) = await _bound_world(seeded) + client = await auth_client(KA_RF) + + response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "csv"}) + + assert response.status_code == HTTP_OK, response.text + assert response.headers["content-type"] == "text/csv; charset=utf-8" + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment; filename=\"checklist.csv\"; filename*=UTF-8''") + assert unquote(disposition.split("filename*=UTF-8''")[1]) == "checklist-ГК Русский бисквит-2024-Q3.csv" + assert disposition.isascii() + lines = response.content.decode("utf-8-sig").splitlines() + cells = ["Отсутствует"] * len(EXPECTED_RULES) + cells[OSV_60] = "Загружен" + assert lines[1] == ";".join([first.name, first.inn or "", *cells]) + assert lines[2] == ";".join([second.name, second.inn or "", *["Отсутствует"] * len(EXPECTED_RULES)]) + assert len(lines) == 3 # noqa: PLR2004 - заголовок и два ЮЛ + + +@pytest.mark.integration +async def test_checklist_csv_lists_several_missing_quarters( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Подтверждён только Q3: ячейка ОСВ 60 — «Отсутствует (нет: Q1, Q2)».""" + version, (first, _) = await _world(seeded) + await _bound_osv(seeded, version, first, "60", Q3) + client = await auth_client(KA_RF) + + response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "csv"}) + + row = response.content.decode("utf-8-sig").splitlines()[1].split(";") + assert row[2 + OSV_60] == "Отсутствует (нет: Q1, Q2)" + + +@pytest.mark.integration +async def test_checklist_unknown_format_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _world(seeded) + client = await auth_client(KA_RF) + response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "xml"}) + assert response.status_code == HTTP_UNPROCESSABLE + + +@pytest.mark.integration +async def test_checklist_csv_of_invisible_version_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Чужая (невидимая) версия: CSV — тот же 404, что и JSON, без файла.""" + version, _ = await _bound_world(seeded) + foreign = await auth_client(FOREIGN) # CONSOLIDATION_VIEW есть, но Краснодарский филиал + for fmt in ("csv", "json"): + response = await foreign.get(f"/consolidations/versions/{version.id}/checklist", params={"format": fmt}) + assert response.status_code == HTTP_NOT_FOUND, fmt + assert "content-disposition" not in response.headers diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 1de818d..f7edaff 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -618,3 +618,59 @@ async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFact patched = await client.patch(f"/directories/legal-entities/{entity.id}", json={"name": "ООО Счётчик плюс"}) assert patched.status_code == HTTP_OK, patched.text assert patched.json()["documents_count"] == expected_count # T11.2: тот же подсчёт в PATCH + + +async def _with_documents(session: AsyncSession, entity: Any, accounts: Sequence[str]) -> None: + """Один обработанный файл ЮЛ с документами по перечисленным счетам.""" + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + for account in accounts: + await make_document(session, file=file, job=job, account=account) + + +@pytest.mark.integration +async def test_documents_count_zero_and_grouped_per_entity( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """D-13: у ЮЛ без документов счётчик 0 (а не пропуск), в списке из нескольких ЮЛ счёт идёт по каждому ЮЛ.""" + group = await make_company_group(seeded, name="ГК Счётчики") + empty = await make_legal_entity(seeded, name="ООО Пустое", group=group) + single = await make_legal_entity(seeded, name="ООО Одиночное", group=group) + triple = await make_legal_entity(seeded, name="ООО Тройное", group=group) + await _with_documents(seeded, single, ["60"]) + await _with_documents(seeded, triple, ["60", "62", "66"]) + client = await auth_client("ka_go@armka.local") + + listed = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json() + counts = {item["id"]: item["documents_count"] for item in listed["items"]} + assert counts == {str(empty.id): 0, str(single.id): 1, str(triple.id): 3} + card = (await client.get(f"{ENTITIES}/{empty.id}")).json() + assert card["documents_count"] == 0 + + +@pytest.mark.integration +async def test_documents_count_in_move_and_withdraw_responses( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Ответы move/withdraw (как PATCH) несут реальный `documents_count` (PATCH покрыт `test_documents_count`).""" + rostov = await _office(seeded, ROSTOV) + old_group = await make_company_group(seeded, name="ГК Откуда", office=rostov) + new_group = await make_company_group(seeded, name="ГК Куда", office=rostov) + moved = await make_legal_entity(seeded, name="ООО Переезд", group=old_group) + closed = await make_legal_entity(seeded, name="ООО Закрытие", group=old_group) + await _with_documents(seeded, moved, ["60", "62"]) + await _with_documents(seeded, closed, ["60", "62", "66"]) + client = await auth_client("ka_rf@armka.local") + today = _today().isoformat() + + move = await client.post( + f"{ENTITIES}/{moved.id}/move", json={"company_group_id": str(new_group.id), "effective_date": today} + ) + assert move.status_code == HTTP_OK, move.text + assert move.json()["documents_count"] == 2 # noqa: PLR2004 - два документа ЮЛ + withdraw = await client.post( + f"{ENTITIES}/{closed.id}/withdraw", json={"event": "CLOSE", "effective_date": today, "comment": "Ликвидация"} + ) + assert withdraw.status_code == HTTP_OK, withdraw.text + assert withdraw.json()["documents_count"] == 3 # noqa: PLR2004 - три документа ЮЛ diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 1d9ba08..2d7193e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -738,3 +738,136 @@ async def test_patch_units_are_stripped( response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": " тыс. руб. "}}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["header"]["units"]["value"] == "тыс. руб." + + +async def undated_form(session: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> Document: + """Форма без дат (снимок `st03_no_codes` без `report_date`; колонок в нём нет): период не определяется.""" + source = fixtures_dir / GOLDEN_STATEMENTS / "synthetic" / "st03_no_codes.json" + data = json.loads(source.read_text("utf-8")) + for item in data["documents"]: + item["header"]["report_date"] = item["header"]["report_date_text"] = None + path = tmp_path / "st03_undated.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + entity = await rostov_entity(session, path, name="ООО Без дат") + (document,) = await map_golden_snapshot(session, entity=entity, path=path, uploaded_by=None) + return document + + +async def test_edition_66n_for_date_up_to_2024_12_31( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """D-14: редакция 66н — по дате формы ≤ 31.12.2024 (правка даты отчёта меняет редакцию и в ответе, и в чтении).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + assert (await client.get(url)).json()["edition"] == "fsbu4" + edited = await client.patch(url, json={"header": {"report_date": "2024-12-31"}}) + assert edited.status_code == HTTPStatus.OK, edited.text + assert edited.json()["edition"] == "66n" and edited.json()["document"]["period_quarter"] == "Q4" + assert (await client.get(url)).json()["edition"] == "66n" + await client.patch(url, json={"header": {"report_date": "2025-03-31"}}) + assert (await client.get(url)).json()["edition"] == "fsbu4" # граница: первый день после 31.12.2024 уже ФСБУ 4 + + +async def test_edition_null_without_dates( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path +) -> None: + document = await undated_form(seeded, fixtures_dir, tmp_path) + assert document.reporting_date is None and document.period_year is None + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["edition"] is None and body["document"]["period_quarter"] is None + assert body["checks"]["cross_form"] is None + + +async def test_cross_form_null_when_quarter_not_defined( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Документ без квартала: межформенная проверка не выполняется (без пары по периоду) и не ломает чтение.""" + balance, income = await two_forms(seeded, fixtures_dir) + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) + await set_amount(seeded, income, "2400", Decimal("150"), current=True) + client = await auth_client(KA_RF) + url = f"/documents/{balance.id}/statement" + assert (await client.get(url)).json()["checks"]["cross_form"] is not None # контроль: пара и нарушение есть + edited = await client.patch(url, json={"header": {"report_date": "2025-10-31"}}) + assert edited.status_code == HTTPStatus.OK, edited.text + assert edited.json()["document"]["period_quarter"] is None and edited.json()["checks"]["cross_form"] is None + response = await client.get(url) + assert response.status_code == HTTPStatus.OK and response.json()["checks"]["cross_form"] is None + + +async def test_cross_form_prefers_confirmed_pair_over_newer( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """D-5: подтверждённая форма-пара выигрывает у более новой неподтверждённой; без подтверждения — самая новая.""" + balance, old_income = await two_forms(seeded, fixtures_dir) + entity = await seeded.get(LegalEntity, balance.legal_entity_id) + assert entity is not None + newer_income = next( + d + for d in await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) + if d.document_type == DocumentType.INCOME + ) + # повторная загрузка заменяет прежний файл; возвращаем его в обработанные — оба претендуют на роль пары + await seeded.execute( + update(StoredFile).where(StoredFile.id == old_income.stored_file_id).values(status=FileStatus.PROCESSED) + ) + assert newer_income.created_at > old_income.created_at + await set_amount(seeded, balance, "1370", Decimal("500"), current=True) + await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) + await set_amount(seeded, old_income, "2400", Decimal("150"), current=True) # нарушает 500 − 300 = 200 + await set_amount(seeded, newer_income, "2400", Decimal("200"), current=True) # выполняет + client = await auth_client(KA_RF) + url = f"/documents/{balance.id}/statement" + assert (await client.get(url)).json()["checks"]["cross_form"] is None # без подтверждения — более новая пара + await seeded.execute(update(Document).where(Document.id == old_income.id).values(status=DocumentStatus.CONFIRMED)) + cross = (await client.get(url)).json()["checks"]["cross_form"] + assert cross is not None and cross["actual"] == "150.00" # подтверждённая пара, хотя она старее + + +async def test_statement_requires_document_view( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Роль без права `DOCUMENT_VIEW` (ADMIN) получает 403 на чтение формы, правку и акцепт.""" + document = await form(seeded, fixtures_dir) + client = await auth_client("admin@armka.local") + url = f"/documents/{document.id}/statement" + assert (await client.get(url)).status_code == HTTPStatus.FORBIDDEN + assert (await client.patch(url, json={"header": {"units": "руб."}})).status_code == HTTPStatus.FORBIDDEN + assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.FORBIDDEN + + +async def test_confirm_form_without_meta_has_no_conditions( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Решение №16: документ типа BALANCE без шапки формы (старые данные) подтверждается без условий D-9.""" + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО Без шапки") + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + document.document_type, document.osv_account_code = DocumentType.BALANCE, None + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.post(f"/documents/{document.id}/confirm") + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["status"] == "CONFIRMED" + + +async def test_confirm_undated_form_requires_period( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path +) -> None: + """Форма без дат (`period_year` NULL): подтверждение — 409 `PERIOD_REQUIRED`; после правки даты — проходит.""" + document = await undated_form(seeded, fixtures_dir, tmp_path) + assert document.period_year is None + client = await auth_client(KA_RF) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2024-12-31"}}) + assert edit.status_code == HTTPStatus.OK, edit.text + assert edit.json()["document"]["period_year"] == 2024 # noqa: PLR2004 - год даты из правки + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK diff --git a/api/tests/test_api_users_brief.py b/api/tests/test_api_users_brief.py index 794cbd6..5989410 100644 --- a/api/tests/test_api_users_brief.py +++ b/api/tests/test_api_users_brief.py @@ -2,17 +2,22 @@ import uuid +import httpx import pytest +from fastapi import FastAPI from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.api.endpoints.users import MAX_BRIEF_IDS from src.models.access import AppUser +from src.models.directories import RegionalOffice from tests.conftest import AuthClientFactory +from tests.factories import make_user pytestmark = pytest.mark.integration HTTP_OK = 200 +HTTP_UNAUTHORIZED = 401 HTTP_UNPROCESSABLE = 422 KA_RF = "ka_rf@armka.local" @@ -35,3 +40,47 @@ async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory response = await client.get("/users/brief", params={"ids": too_many}) assert response.status_code == HTTP_UNPROCESSABLE and response.json()["detail"]["code"] == "TOO_MANY_IDS" assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == HTTP_UNPROCESSABLE + + +async def test_brief_without_token_is_401(app: FastAPI, seeded: AsyncSession) -> None: # noqa: ARG001 + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test/api/v1") as anonymous: + response = await anonymous.get("/users/brief", params={"ids": str(uuid.uuid4())}) + assert response.status_code == HTTP_UNAUTHORIZED + + +async def test_brief_includes_inactive_user(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one() + gone = await make_user( + seeded, email="gone@armka.local", office=office, full_name="Уволенный Сотрудник", is_active=False + ) + client = await auth_client(KA_RF) + response = await client.get("/users/brief", params={"ids": str(gone.id)}) + assert response.status_code == HTTP_OK + assert response.json() == [{"id": str(gone.id), "full_name": "Уволенный Сотрудник", "is_active": False}] + + +async def test_brief_invalid_uuid_code(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ARG001 + client = await auth_client(KA_RF) + response = await client.get("/users/brief", params={"ids": f"{uuid.uuid4()},not-a-uuid"}) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "INVALID_IDS" + + +@pytest.mark.parametrize("ids", ["", " ", ",", " , ,"]) +async def test_brief_empty_ids_is_empty_list( + seeded: AsyncSession, + auth_client: AuthClientFactory, + ids: str, # noqa: ARG001 +) -> None: + client = await auth_client(KA_RF) + response = await client.get("/users/brief", params={"ids": ids}) + assert response.status_code == HTTP_OK and response.json() == [] + + +async def test_brief_duplicates_and_trailing_comma(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + client = await auth_client(KA_RF) + response = await client.get("/users/brief", params={"ids": f" {user.id} ,{user.id},{str(user.id).upper()},"}) + assert response.status_code == HTTP_OK + assert [item["id"] for item in response.json()] == [str(user.id)] # дубли схлопываются, хвостовая запятая допустима diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index 6adef73..c7f7d31 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -33,6 +33,54 @@ def test_render() -> None: assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)' +def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut: + return ChecklistCellOut(status=status, missing_quarters=list(quarters), document_ids=[]) + + +def test_missing_quarters_listed_in_order() -> None: + checklist = ChecklistOut( + rules=[ChecklistRuleOut(document_type=DocumentType.OSV, account="60", is_mandatory=True)], + rows=[ + ChecklistRowOut( + legal_entity_id=uuid.uuid4(), + legal_entity_name="ООО Альфа", + legal_entity_inn="7700000001", + cells=[_cell(CellStatus.MISSING, PeriodQuarter.Q1, PeriodQuarter.Q2)], + ) + ], + ) + lines = render_checklist_csv(checklist).decode("utf-8-sig").splitlines() + assert lines == ["Организация;ИНН;ОСВ 60", "ООО Альфа;7700000001;Отсутствует (нет: Q1, Q2)"] + + +def test_all_statuses_rendered_with_their_labels() -> None: + """Каждый статус ячейки получает свою подпись: обработка, проверка (`NEEDS_CHECK` и `RECOGNIZED`), ошибка.""" + statuses = [ + CellStatus.CONFIRMED, + CellStatus.PROCESSING, + CellStatus.NEEDS_CHECK, + CellStatus.RECOGNIZED, + CellStatus.ERROR, + CellStatus.MISSING, + ] + rules = [ChecklistRuleOut(document_type=DocumentType.INCOME, account=None, is_mandatory=True)] * len(statuses) + checklist = ChecklistOut( + rules=rules, + rows=[ + ChecklistRowOut( + legal_entity_id=uuid.uuid4(), + legal_entity_name="ООО Бета", + legal_entity_inn="7700000000", + cells=[_cell(status) for status in statuses], + ) + ], + ) + lines = render_checklist_csv(checklist).decode("utf-8-sig").splitlines() + assert lines[1] == ( + "ООО Бета;7700000000;Загружен;Обработка;Требуется проверка;Требуется проверка;Ошибка загрузки;Отсутствует" + ) + + def test_filename() -> None: assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv" diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index e98a138..4d4a18b 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -93,6 +93,84 @@ def test_target_without_self_sum_hard_failure() -> None: ) +def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]: + return [item for item in items if item.relation == relation] + + +def test_relation_checked_per_column_and_none_is_zero() -> None: + # колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0) + lines = _lines( + ("1100", {0: "300", 1: "300", 2: "300"}), + ("1200", {0: "600", 1: "600", 2: None}), + ("1600", {0: "900", 1: "1000", 2: "300"}), + ) + report = run_checks("BALANCE", lines, [0, 1, 2], RULES) + assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ + FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900")) + ] + assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] + + +def test_tolerance_exceeded_by_one_unit_is_violation() -> None: + # допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает + lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"})) + report = run_checks("BALANCE", lines, [0], RULES) + assert _by_relation(report.failed, "1600 = 1100 + 1200") == [ + FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900")) + ] + + +def test_tolerance_boundary_is_inclusive() -> None: + lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})) + report = run_checks("BALANCE", lines, [0], RULES) + assert _by_relation(report.failed, "1600 = 1100 + 1200") == [] + assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == [] + + +def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None: + # 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю + holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES) + assert _by_relation(holds.failed, "2100 = 2110 - 2120") == [] + broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES) + failed = _by_relation(broken.failed, "2100 = 2110 - 2120") + assert len(failed) == 1 + assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40")) + + +def test_subline_attached_to_last_parent_by_row_order() -> None: + # M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет + lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) + report = run_checks("BALANCE", lines, [0], RULES) + assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [ + "1230 = 12310 + 12320" + ] + + +def test_subline_prefers_last_parent_over_prefix() -> None: + # 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть + lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"})) + report = run_checks("BALANCE", lines, [0], RULES) + relations = {item.relation for item in report.soft_failed} + assert "1230 = 12310 + 12320" in relations + assert not any(relation.startswith("1231 =") for relation in relations) + + +def test_rounding_threshold_boundary() -> None: + # порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100 + assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100") + halves = {0: "4999950"} + at_threshold = run_checks( + "BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES + ) + assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == [] + assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [ + D("100") + ] + above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES) + assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")] + assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == [] + + @pytest.mark.parametrize( ("b1370", "i2400", "expected"), [ diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py index 515ce1a..29df1be 100644 --- a/api/tests/test_domain_statement_mapping.py +++ b/api/tests/test_domain_statement_mapping.py @@ -153,3 +153,59 @@ def test_values_ok() -> None: assert not values_ok([(False, False, hundred)]) assert not values_ok([(False, True, Decimal("99"))]) assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз + + +def test_equal_column_dates_current_is_lowest_index_only() -> None: + columns = [ + {"column_index": 2, "label": "б", "period_date": "2025-09-30"}, + {"column_index": 1, "label": "а", "period_date": "2025-09-30"}, + {"column_index": 3, "label": "в", "period_date": "2024-12-31"}, + ] + draft = build_statement(_doc(columns=columns, lines=[])) + assert {index: current for index, _, _, current in draft.columns} == {1: True, 2: False, 3: False} + + +def test_column_without_date_next_to_dated_is_not_current() -> None: + columns = [ + {"column_index": 0, "label": "примечание"}, + {"column_index": 1, "label": "На 30.09.2025", "period_date": "2025-09-30"}, + ] + draft = build_statement(_doc(columns=columns, lines=[])) + assert {index: current for index, _, _, current in draft.columns} == {0: False, 1: True} + + +def test_duplicate_code_values_belong_to_first_line() -> None: + lines = [ + { + "line_code": "1150", + "line_name": "первая", + "values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}], + }, + { + "line_code": "1150", + "line_name": "дубль", + "values": [{"column_index": 0, "amount": "999", "confidence": 100, "is_recognized": True}], + }, + ] + draft = build_statement(_doc(lines=lines)) + assert [line.line_name for line in draft.lines] == ["первая"] + assert [(v.line_code, v.column_index, v.amount) for v in draft.values] == [ + ("1150", 0, Decimal("10")), + ("1150", 1, None), + ] + + +def test_fractional_confidence_kept_exactly() -> None: + lines = [ + { + "line_code": "1150", + "line_code_confidence": 99.5, + "line_name": "ОС", + "line_name_confidence": 98.25, + "values": [{"column_index": 0, "amount": "10", "confidence": 99.5, "is_recognized": True}], + } + ] + draft = build_statement(_doc(lines=lines)) + assert draft.lines[0].line_code_confidence == Decimal("99.5") + assert draft.lines[0].line_name_confidence == Decimal("98.25") + assert draft.values[0].confidence == Decimal("99.5") diff --git a/api/tests/test_fixtures_marker.py b/api/tests/test_fixtures_marker.py index 88b03a4..f54eebd 100644 --- a/api/tests/test_fixtures_marker.py +++ b/api/tests/test_fixtures_marker.py @@ -1,5 +1,6 @@ """Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»).""" +import json import uuid from pathlib import Path @@ -53,3 +54,29 @@ def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None: assert result.job_id == job_id and result.file_id == file_id assert result.service.name == "scan-gateway" assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"] + assert result.status == "needs_check" and result.schema_version == "1.2" + assert result.error is None + assert result.metrics is not None and result.metrics.rows == 60 # noqa: PLR2004 - эталон aep_6 + + +@pytest.mark.fixtures +def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None: + """Эталон с ошибкой: код и сообщение переносятся в результат, метрик нет.""" + result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4()) + assert result.status == "error" and result.documents == [] + assert result.error is not None and result.error.code == "INVALID_DOCUMENT" + assert result.metrics is None + + +def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None: + """Пустой объект `metrics` передаётся как есть (сравнение `is not None`, а не по истинности).""" + snapshot = tmp_path / "empty.json" + snapshot.write_text( + json.dumps( + {"status": "error", "error": {"code": "UNREADABLE", "message": "m"}, "metrics": {}, "documents": []} + ), + "utf-8", + ) + result = ocr_result(snapshot, job_id=uuid.uuid4(), file_id=uuid.uuid4()) + assert result.metrics is not None and result.metrics.rows is None + assert result.error is not None and result.error.code == "UNREADABLE" diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 1c833ad..996ba8f 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -109,6 +109,28 @@ def _check_sheet( # noqa: PLR0912 - все инварианты одного л return problems, missing +async def _form_problems(session: AsyncSession, file_id: uuid.UUID, raw: dict[str, Any], name: str) -> list[str]: + """Формы смешанных снимков (s12 и др.): по документу на каждый тип ББ/ОФР снимка (повтор формы пропускается), + `DISCLOSURE`/`OTHER` — по одному документу на тип.""" + types = Counter( + document["document_type"] for document in raw["documents"] if document["document_type"] != DocumentType.OSV + ) + stored = Counter( + ( + await session.execute( + select(Document.document_type).where( + Document.stored_file_id == file_id, Document.document_type != DocumentType.OSV + ) + ) + ).scalars() + ) + return [ + f"{name}: документов {kind} {stored.get(kind, 0)}, ожидалось {1 if types[kind] else 0}" + for kind in sorted({*types, *stored}) + if stored.get(kind, 0) != (1 if types[kind] else 0) + ] + + async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: Path) -> None: # noqa: PLR0914 """«Все golden-снимки парсера» (не меньше 113) маппятся в БД, инварианты спеки на каждом документе, ≤ 60 с.""" session = seeded @@ -156,6 +178,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P ) ).scalars() } + problems.extend(await _form_problems(session, file_id, raw, name)) sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"] if set(documents) != {sheet["locator"] for sheet in sheets}: problems.append(f"{name}: документы {sorted(documents)} не совпадают с листами снимка") diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index 54e5224..31da7a4 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -3,23 +3,34 @@ import json import uuid +from datetime import UTC, datetime from pathlib import Path import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from src.models.accounting import ChartOfAccount from src.models.directories import LegalEntity -from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter -from src.models.files import Document, StoredFile +from src.models.enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, RecognitionJob, StoredFile from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) +from src.repositories.counterparties import CounterpartyIndex +from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file from tests.factories import make_legal_entity -from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result +from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -117,6 +128,7 @@ async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> N (balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) assert balance.status is DocumentStatus.NEEDS_CHECK meta = await _meta(seeded, balance) + assert meta.inn == "7700000000" and meta.organization_name == 'ООО "Тестовая организация"' assert await _count(seeded, FinancialStatementLine, meta.id) == 0 # в снимке st03_no_codes колонок нет (`columns = []`): только шапка (расхождение со spec «и колонками») assert await _count(seeded, FinancialStatementColumn, meta.id) == 0 @@ -129,6 +141,23 @@ async def test_mixed_book_osv_and_balance(seeded: AsyncSession, fixtures_dir: Pa assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE} +async def test_mixed_osv_snapshot_with_balance(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Снимок парсера ОСВ `s12` с листом ББ: лист ОСВ и по одной форме на лист ББ снимка.""" + owner = await make_legal_entity(seeded, name="ООО Смешанная ОСВ") + path = fixtures_dir / "golden" / "expected_osv_parse" / "synthetic" / "s12_statement_and_osv.json" + raw = json.loads(path.read_text("utf-8")) + forms = [d for d in raw["documents"] if d["document_type"] in ("BALANCE", "INCOME")] + assert forms, "в снимке s12 больше нет формы: тест вырожден" + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + stored_forms = [d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)] + assert sorted(d.document_type for d in stored_forms) == sorted(f["document_type"] for f in forms) + assert [d.document_type for d in documents].count(DocumentType.OSV) == sum( + 1 for d in raw["documents"] if d["document_type"] == "OSV" + ) + for document in stored_forms: + assert (await _meta(seeded, document)).id is not None + + async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None: owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж") documents = await map_golden_snapshot( @@ -202,3 +231,77 @@ async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) assert [d.document_type for d in documents] == [DocumentType.INCOME] assert documents[0].status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED + assert await _mapping_warnings(seeded, documents[0]) == [ + "sheet:повтор: вторая форма INCOME в файле пропущена (OQ-77)" + ] + + +async def test_form_with_needs_check_job_is_needs_check(seeded: AsyncSession, fixtures_dir: Path) -> None: + """D-6: условие «задание done» — форма чистого снимка при задании `needs_check` не `RECOGNIZED`.""" + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, path) + (income,) = await map_golden_snapshot( + seeded, entity=owner, path=path, uploaded_by=None, job_status=RecognitionStatus.needs_check + ) + assert income.counterparty_check is DocumentCheckResult.MATCH + assert ( + income.status is DocumentStatus.NEEDS_CHECK + ) # то же при задании done — RECOGNIZED (test_clean_income_recognized) + + +async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Spec «Повторный маппинг идемпотентен»: файл возвращён в `PROCESSING` и смаплен снова — один набор шапок, колонок, + строк и значений, никаких остатков; формы другого файла не тронуты.""" + path = next((fixtures_dir / NPZ).glob("*.json")) + owner = await _owner(seeded, path) + bystander = await make_legal_entity(seeded, name="ООО Сосед") + other = await map_golden_snapshot( + seeded, entity=bystander, path=fixtures_dir / MEZH / "1_Форма1_бух_09.json", uploaded_by=None + ) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + before = await _statement_totals(seeded) + file_id = documents[0].stored_file_id + file = await seeded.get(StoredFile, file_id) + assert file is not None and file.current_recognition_job_id is not None + job = await seeded.get(RecognitionJob, file.current_recognition_job_id) + assert job is not None + file.status = FileStatus.PROCESSING + await seeded.flush() + chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))} + result = golden_result(path, job_id=job.id, file_id=file.id) + + await map_file( + seeded, + job=job, + file=file, + result=result, + chart=chart, + index=await CounterpartyIndex.load(seeded), + now=datetime.now(UTC), + ) + + assert await _statement_totals(seeded) == before + again = (await seeded.execute(select(Document).where(Document.stored_file_id == file_id))).scalars().all() + assert {d.document_type for d in again} == {DocumentType.BALANCE, DocumentType.INCOME} + assert {d.id for d in again}.isdisjoint({d.id for d in documents}) # документы пересозданы, не дублированы + assert file.status is FileStatus.PROCESSED + kept = ( + await seeded.execute(select(Document).where(Document.stored_file_id == other[0].stored_file_id)) + ).scalar_one() + assert kept.id == other[0].id + + +async def _statement_totals(session: AsyncSession) -> dict[str, int]: + """Число строк во всех таблицах формы: любой остаток или дубль после повторного маппинга меняет итог.""" + counts: dict[str, int] = {} + for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue): + counts[model.__tablename__] = (await session.execute(select(func.count()).select_from(model))).scalar_one() + return counts + + +async def _mapping_warnings(session: AsyncSession, document: Document) -> list[str]: + file = await session.get(StoredFile, document.stored_file_id) + assert file is not None and file.current_recognition_job_id is not None + job = await session.get(RecognitionJob, file.current_recognition_job_id) + assert job is not None + return list(job.metrics[MAPPING_WARNINGS_KEY]) -- 2.47.2 From dd7f7cc42d792f93c6752b2638c601f627f86221 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:26:34 +0400 Subject: [PATCH 29/36] =?UTF-8?q?fix(files):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20?= =?UTF-8?q?=D1=83=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=91=D0=91/=D0=9E=D0=A4=D0=A0=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20(C-1,=20T8.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 6 +- api/src/repositories/files.py | 11 +++- api/src/services/primary_files.py | 3 +- api/tests/test_api_files_delete.py | 61 ++++++++++++++++++- .../changes/slice-3b-statements-api/design.md | 2 +- 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 153004a..524011f 100644 --- a/README.md +++ b/README.md @@ -574,8 +574,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл `{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`) подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ. - **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. - Комментарии аудит не пишут; при удалении файла комментарии его документов остаются в таблице `comment` без владельца - (полиморфная связь без FK, решение №18; наружу не отдаются). + Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами + (полиморфная связь без FK; решение №18 закрыто). Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным — `src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`. @@ -590,7 +590,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний уходит в `SUPERSEDED` целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой. -**Удаление.** `DELETE /files/{id}` удаляет строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3 +**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3 (`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()` (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`) и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 3ece499..98d834e 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -22,6 +22,7 @@ from src.models.directories import LegalEntity from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.repositories import statements as statements_repo COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа @@ -199,12 +200,18 @@ async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: - """Удаляет строки ОСВ, документы, все задания и сам файл. FK-цикл `stored_file.current_recognition_job_id` ↔ - `recognition_job.file_id` разрывается обнулением ссылки до удаления заданий. Блокировки — у вызывающего.""" + """Удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, все + задания и сам файл (формы и комментарии — до документов и заданий: на них FK без CASCADE). FK-цикл + `stored_file.current_recognition_job_id` ↔ `recognition_job.file_id` разрывается обнулением ссылки до удаления + заданий. Блокировки — у вызывающего.""" file_id = file.id file.current_recognition_job_id = None await session.flush() document_ids = select(Document.id).where(Document.stored_file_id == file_id) + await statements_repo.delete_file_statements(session, file_id) + await session.execute( + delete(Comment).where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id.in_(document_ids)) + ) await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(document_ids))) await session.execute(delete(Document).where(Document.stored_file_id == file_id)) await session.execute(delete(RecognitionJob).where(RecognitionJob.file_id == file_id)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 7d29469..bd3e34a 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -287,7 +287,8 @@ class PrimaryFilesService: `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл, исчезнувший, пока ждали блокировку, — 404. - Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий + Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания + и `stored_file`; `s3_object` файла и результатов заданий остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает ретеншн бакета. `before_value` аудита — карточка файла.""" diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index b45e4bf..918bfe0 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -6,6 +6,7 @@ design D-10). """ import asyncio +import datetime import json import uuid from pathlib import Path @@ -16,11 +17,18 @@ from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.core.config import get_settings -from src.models.audit_ex import AuditEvent +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent, Comment from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.services.result_mapping import process_once from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( @@ -31,6 +39,7 @@ from tests.factories import ( make_result_object, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_NO_CONTENT = 204 HTTP_FORBIDDEN = 403 @@ -102,6 +111,56 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.after_value is None +MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" + + +async def _form_file_with_comment(session: AsyncSession, fixtures_dir: Path) -> uuid.UUID: + """Файл с формой ББ (golden) и комментарием к её документу; возвращает id файла.""" + entity = await _rostov_entity(session) + (document,) = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + author = (await session.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + session.add( + Comment( + object_type="document", + object_id=document.id, + text="проверить", + author_id=author, + created_at=datetime.datetime.now(datetime.UTC), + ) + ) + await session.flush() + return document.stored_file_id + + +async def _assert_form_rows_gone(session: AsyncSession, file_id: uuid.UUID, document_ids: list[uuid.UUID]) -> None: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue): + assert await _count(session, model) == 0, model.__tablename__ + assert await _count(session, Comment, Comment.object_id.in_(document_ids)) == 0 + + +@pytest.mark.fixtures +@pytest.mark.parametrize("superseded", [False, True]) +async def test_delete_file_with_statement_form( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, superseded: bool +) -> None: + """C-1, T8.2: удаление файла с формой ББ/ОФР убирает шапку, колонки, строки, значения и комментарии документов + (раньше — падение по FK `financial_statement_meta`); то же для файла `SUPERSEDED`.""" + file_id = await _form_file_with_comment(seeded, fixtures_dir) + document_ids = list((await seeded.execute(select(Document.id).where(Document.stored_file_id == file_id))).scalars()) + assert await _count(seeded, FinancialStatementValue) > 0 and await _count(seeded, Comment) == 1 + if superseded: + await seeded.execute(update(StoredFile).where(StoredFile.id == file_id).values(status=FileStatus.SUPERSEDED)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + await _assert_form_rows_gone(seeded, file_id, document_ids) + + async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index a1927d7..b97b3fd 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -177,5 +177,5 @@ seed-dev` с нуля. | 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) | | 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 | | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | -| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей | +| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей. **Закрыто (fix wave, T8.2): комментарии удаляются вместе с документами** в `delete_file_rows` — удаление файла и так обязано чистить зависимые от документов строки (формы ББ/ОФР, C-1), лишний `DELETE` дёшев, а осиротевшие строки не нужны никому | | 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула | -- 2.47.2 From f9500d3f09523b67ed780da706922fa84021aa15 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:27:38 +0400 Subject: [PATCH 30/36] =?UTF-8?q?fix(statements):=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20column=5Findex=20=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=20(=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0)=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B5=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20(I-1,=20T2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/statement_mapping.py | 33 ++++++++++++++++------ api/tests/test_domain_statement_mapping.py | 32 +++++++++++++++++++++ api/tests/test_mapping_statements.py | 30 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py index 6a4bfdc..4c34b14 100644 --- a/api/src/domain/statement_mapping.py +++ b/api/src/domain/statement_mapping.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from src.contract.result import StatementDocument, StatementValue +from src.contract.result import StatementColumn, StatementDocument, StatementValue from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType __all__ = [ @@ -92,12 +92,21 @@ def _decimal(value: float | None) -> Decimal | None: def build_statement(doc: StatementDocument) -> StatementDraft: - """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение - на каждую ячейку.""" + """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки, значение на каждую ячейку. + + Повтор кода строки, `column_index` колонки и пары (строка, колонка) — берётся первое, о повторе — предупреждение.""" document_type = DocumentType(doc.document_type) header = doc.header warnings: list[str] = [] - column_dates = [column.period_date for column in doc.columns] + source_columns: list[StatementColumn] = [] + seen_indexes: set[int] = set() + for column in doc.columns: + if column.column_index in seen_indexes: + warnings.append(f"{doc.locator}: колонка {column.column_index} повторяется — оставлена первая") + continue + seen_indexes.add(column.column_index) + source_columns.append(column) + column_dates = [column.period_date for column in source_columns] period_date = statement_period(header.report_date, column_dates) period_year = period_date.year if period_date is not None else None period_quarter = quarter_of(period_date) if period_date is not None else None @@ -114,11 +123,11 @@ def build_statement(doc: StatementDocument) -> StatementDraft: None if latest is None else column.period_date == latest - and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest), + and column.column_index == min(c.column_index for c in source_columns if c.period_date == latest), ) - for column in doc.columns + for column in source_columns ] - known_columns = {column.column_index for column in doc.columns} + known_columns = seen_indexes lines: list[LineDraft] = [] values: list[ValueDraft] = [] seen: set[str] = set() @@ -143,8 +152,14 @@ def build_statement(doc: StatementDocument) -> StatementDraft: " вне колонок формы пропущено" ) continue - by_column.setdefault(cell_value.column_index, cell_value) - for column in doc.columns: + if cell_value.column_index in by_column: + warnings.append( + f"{doc.locator}: строка {line.line_code}: значение колонки {cell_value.column_index}" + " повторяется — оставлено первое" + ) + continue + by_column[cell_value.column_index] = cell_value + for column in source_columns: value: StatementValue | None = by_column.get(column.column_index) values.append( ValueDraft( diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py index 29df1be..c3d7dcb 100644 --- a/api/tests/test_domain_statement_mapping.py +++ b/api/tests/test_domain_statement_mapping.py @@ -209,3 +209,35 @@ def test_fractional_confidence_kept_exactly() -> None: assert draft.lines[0].line_code_confidence == Decimal("99.5") assert draft.lines[0].line_name_confidence == Decimal("98.25") assert draft.values[0].confidence == Decimal("99.5") + + +def test_duplicate_column_index_keeps_first_with_warning() -> None: + """I-1: повтор `column_index` в колонках — берётся первая, остальные отброшены с предупреждением; текущая колонка и + значения считаются по оставшимся.""" + columns = [ + {"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"}, + {"column_index": 0, "label": "дубль", "period_date": "2026-01-31"}, + {"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}, + ] + draft = build_statement(_doc(columns=columns)) + assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert "sheet:ББ: колонка 0 повторяется — оставлена первая" in draft.warnings + assert (draft.period_date, draft.period_quarter) == (d(2025, 9, 30), PeriodQuarter.Q3) + assert [(v.line_code, v.column_index) for v in draft.values] == [("1150", 0), ("1150", 1), ("1600", 0), ("1600", 1)] + + +def test_duplicate_cell_keeps_first_value_with_warning() -> None: + """T2.3: повтор пары (строка, колонка) среди значений — первое значение и предупреждение.""" + lines = [ + { + "line_code": "1150", + "line_name": "ОС", + "values": [ + {"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}, + {"column_index": 0, "amount": "77", "confidence": 100, "is_recognized": True}, + ], + } + ] + draft = build_statement(_doc(lines=lines)) + assert {(v.column_index): v.amount for v in draft.values} == {0: Decimal("10"), 1: None} + assert "sheet:ББ: строка 1150: значение колонки 0 повторяется — оставлено первое" in draft.warnings diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index 31da7a4..f43d832 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -236,6 +236,36 @@ async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir ] +async def test_duplicate_column_index_does_not_fail_mapping( + seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path +) -> None: + """I-1: повтор `column_index` в результате не роняет маппинг (раньше — IntegrityError и файл `ERROR`): файл + `PROCESSED`, колонка одна, форма `NEEDS_CHECK` с предупреждением.""" + source = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, source) + data = json.loads(source.read_text("utf-8")) + columns = data["documents"][0]["columns"] + columns.insert(1, columns[0] | {"label": "дубль"}) + path = tmp_path / "duplicate_column.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED + meta = await _meta(seeded, income) + stored = ( + await seeded.execute( + select(FinancialStatementColumn.column_index, FinancialStatementColumn.label).where( + FinancialStatementColumn.meta_id == meta.id + ) + ) + ).all() + first = columns[0] + assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns}) + assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None and file.status is FileStatus.PROCESSED + assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income)) + + async def test_form_with_needs_check_job_is_needs_check(seeded: AsyncSession, fixtures_dir: Path) -> None: """D-6: условие «задание done» — форма чистого снимка при задании `needs_check` не `RECOGNIZED`.""" path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" -- 2.47.2 From e851445866b73d34c982498df39120647d4c0e85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:28:49 +0400 Subject: [PATCH 31/36] =?UTF-8?q?fix(statements):=20=D1=86=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B2=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B8=20=D1=81?= =?UTF-8?q?=D1=83=D0=BC=D0=BC=D1=8B;=20docstring=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BA=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(M-?= =?UTF-8?q?1,=20M-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/statement_checks.py | 22 ++++++++++++---------- api/src/repositories/statements.py | 3 ++- api/tests/test_domain_statement_checks.py | 9 +++++++++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index f758e5b..81e97ca 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -1,10 +1,10 @@ """Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). -Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если в -колонке есть его цель; отсутствующий код и пустая сумма — 0; правая часть считается как записано и с `abs_terms` по -модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше -`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = -четырёхзначный родитель» — мягкая. Результат не хранится (DBML). +Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если +в колонке есть его цель (есть сумма: пустая ячейка — цели нет); отсутствующий код и пустая сумма в правой части — 0; +правая часть считается как записано и с `abs_terms` по модулю, выполнено, если хоть один вариант в пределах +`tolerance`; жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего по модулю числа соотношения — +мягкое; «сумма пятизначных подстрок = четырёхзначный родитель» — мягкая. Результат не хранится (DBML). """ import functools @@ -136,8 +136,9 @@ def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decima def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]: - """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками (по - порядку строк, M-9 2b).""" + """«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками. Порядок строк в БД не + хранится (DBML порядка не знает): «по порядку» — это порядок кодов, в каком их отдаёт `statement_amounts` + (M-9 2b: подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами).""" low, high = CODE_RANGES[form] parents = {code for code in codes if len(code) == _PARENT_LEN} children: dict[str, list[str]] = {} @@ -177,14 +178,15 @@ def run_checks( column_indexes: Sequence[int], rules: CheckRules, ) -> ChecksReport: - """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: - сумма})` по порядку строк.""" + """Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` в порядке кодов + (порядок строк формы в БД не хранится; от него зависит только привязка пятизначных подстрок).""" explicit = list(rules.relations_for(form)) sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else [] hard: list[FailedCheck] = [] soft: list[FailedCheck] = [] for column_index in column_indexes: - amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} + # код присутствует в колонке, только если в ней есть сумма (пустая ячейка — кода нет, как в tabular-parser) + amounts = {code: amount for code, values in lines if (amount := values.get(column_index)) is not None} for rule in [*explicit, *sublines]: if rule not in sublines and rule.target not in amounts: continue diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index ad303df..28c41d0 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -116,7 +116,8 @@ async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> N async def statement_amounts( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: - """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`.""" + """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`. Порядок строк + формы в БД не хранится (DBML не хранит), поэтому порядок строк — лексикографический порядок кодов.""" columns = ( await session.execute( select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 4d4a18b..1838024 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -195,3 +195,12 @@ def test_broken_rules_file(tmp_path) -> None: bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8") with pytest.raises(ValueError, match="statement_checks.yaml"): load_check_rules(bad) + + +def test_target_with_empty_cell_in_column_is_skipped_but_zero_is_checked() -> None: + """M-1: цель присутствует в колонке, только если в ней есть сумма: пустая ячейка (`None`) — цели нет, соотношение + пропущено (как в tabular-parser); явный 0 — цель есть, и при ненулевых слагаемых это нарушение.""" + terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"})) + empty = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) + failed = _by_relation(empty.failed, "1600 = 1100 + 1200") + assert [(item.column_index, item.expected, item.actual) for item in failed] == [(1, D("0"), D("900"))] -- 2.47.2 From ffc01836b7fcc83315f3eb635205c49ae6492b38 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:31:14 +0400 Subject: [PATCH 32/36] =?UTF-8?q?fix(statements):=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20has=5Flines,=20inn=3Dnull=20?= =?UTF-8?q?=E2=80=94=20422,=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BA=D0=B8,=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD=D0=B8=D0=B8=201370/2400=20?= =?UTF-8?q?(M-2,=20M-5,=20M-7,=20M-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 2 +- api/src/api/endpoints/files/read.py | 10 ++++--- api/src/repositories/statements.py | 6 ++++ api/src/schemas/statements.py | 3 +- api/src/services/statements.py | 5 ++-- api/tests/test_api_files_content.py | 4 +++ api/tests/test_api_statements.py | 45 +++++++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 524011f..1430fac 100644 --- a/README.md +++ b/README.md @@ -560,7 +560,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл Подтверждённая форма читается. - **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id, column_id, amount}]}` (до 5000 ячеек; `amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов, - пробельная строка — 422, `null` — «не менять»). Правится только неподтверждённая форма + пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма (`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл — 409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 236a7c3..1aa9c37 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -96,9 +96,10 @@ async def get_file_content( ) -> StreamingResponse: """Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере.""" stream, media_type, header = await service.content(file_id, disposition) - return ClosingStreamingResponse( - stream, media_type=media_type, headers={"Content-Disposition": header, "X-Content-Type-Options": "nosniff"} - ) + headers = {"Content-Disposition": header, "X-Content-Type-Options": "nosniff", "Cache-Control": "private, no-store"} + if disposition == "inline": + headers["Content-Security-Policy"] = "sandbox" # просмотр в браузере: активное содержимое файла не исполняется + return ClosingStreamingResponse(stream, media_type=media_type, headers=headers) @router.delete( @@ -109,5 +110,6 @@ async def get_file_content( summary="Удалить файл", ) async def delete_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> None: - """Удаляет строки ОСВ, документы, задания и файл; объекту S3 ставится `expires_at`; аудит `FILE_DELETE`.""" + """Удаляет формы ББ/ОФР, комментарии, строки ОСВ, документы, задания и файл; объектам S3 ставится `expires_at`; + аудит `FILE_DELETE`.""" await service.delete(file_id) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 28c41d0..dcdafb8 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -113,6 +113,12 @@ async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> N await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) +async def has_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: + """В форме есть строки `financial_statement_line` (единое определение «есть строки» для статуса D-6).""" + stmt = select(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id).exists()) + return bool((await session.execute(stmt)).scalar_one()) + + async def statement_amounts( session: AsyncSession, meta_id: uuid.UUID ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index c546d2a..614ff5f 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -238,7 +238,8 @@ class CellPatch(BaseModel): class HeaderPatch(BaseModel): - """Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`). + """Отсутствующее поле не меняется; `report_date: null` — очистить дату (`model_fields_set`); `inn: null` — 422 + `INVALID_INN` (ИНН нельзя очистить: иначе обходилась бы проверка контрагента). `units: null` — «не менять» (единицы измерения очищать нельзя); непустая строка после обрезки пробелов, до 50 символов (пробельная строка — 422).""" diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 9812950..15d964f 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -121,14 +121,13 @@ class StatementService: if document.status is DocumentStatus.CONFIRMED: return job = await self._session.get(RecognitionJob, meta.recognition_job_id) - _, amounts = await repo.statement_amounts(self._session, meta.id) document.status = statement_status( job_done=job is not None and job.status is RecognitionStatus.done, checks_passed=not await self.hard_failures(document, meta), counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, values_ok=values_ok(await repo.value_flags(self._session, meta.id)), - has_lines=bool(amounts), + has_lines=await repo.has_lines(self._session, meta.id), warnings=(), ) document.updated_at = utc_now() @@ -192,7 +191,7 @@ class StatementService: """Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса.""" document, meta = await self._editable(document_id) header = body.header - if header is not None and header.inn is not None and not INN_RE.fullmatch(header.inn): + if header is not None and "inn" in header.model_fields_set and not INN_RE.fullmatch(header.inn or ""): raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр") before: dict[str, Any] = {} after: dict[str, Any] = {} diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py index 40b6eea..1131577 100644 --- a/api/tests/test_api_files_content.py +++ b/api/tests/test_api_files_content.py @@ -49,6 +49,8 @@ async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFact assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" assert response.content == PDF assert response.headers["x-content-type-options"] == "nosniff" # T9.5 + assert response.headers["cache-control"] == "private, no-store" # M-7 + assert response.headers["content-security-policy"] == "sandbox" # inline: скрипты внутри файла не исполняются async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 @@ -58,6 +60,8 @@ async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: client = await auth_client(KA_RF) response = await client.get(f"/files/{file.id}/content") assert response.headers["content-type"] == "application/vnd.ms-excel" + assert response.headers["cache-control"] == "private, no-store" + assert "content-security-policy" not in response.headers # только для inline assert response.headers["content-disposition"].startswith( "attachment; filename=\"file.xls\"; filename*=UTF-8''%D0%9E" ) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 2d7193e..03fbddd 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -221,6 +221,16 @@ async def test_cross_form_violation_is_reported_on_both_forms( "expected": "200.00", "actual": "150.00", }, document.document_type + # мягкая межформенная проверка не блокирует подтверждение: подмена 1370 ломает и жёсткие проверки самого ББ — + # после их акцепта форма с нарушением 1370↔2400 подтверждается + failures = (await client.post(f"/documents/{balance.id}/confirm")).json()["detail"]["failures"] + assert failures and all(item["relation"] != CROSS_FORM_RELATION for item in failures) + accepted = await client.post(f"/documents/{balance.id}/statement/checks/accept", json={"comment": "тест"}) + assert accepted.status_code == HTTPStatus.OK, accepted.text + assert accepted.json()["checks"]["cross_form"] is not None + confirmed = await client.post(f"/documents/{balance.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + assert confirmed.json()["status"] == "CONFIRMED" # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена await set_amount(seeded, income, "2400", Decimal("200"), current=True) body = (await client.get(f"/documents/{balance.id}/statement")).json() @@ -357,6 +367,21 @@ async def test_patch_inn_validates_and_rechecks( assert other.json()["document"]["status"] == "NEEDS_CHECK" +async def test_patch_inn_null_is_invalid_and_keeps_inn( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """M-5: `inn: null` — 422 `INVALID_INN` (ИНН не очищается, MISMATCH не обходится), данные не меняются.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + before = (await client.get(url)).json() + response = await client.patch(url, json={"header": {"inn": None}}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert response.json()["detail"]["code"] == "INVALID_INN" + assert (await client.get(url)).json() == before + assert (await client.patch(url, json={"header": {"units": "тыс. руб."}})).status_code == HTTPStatus.OK # без inn + + async def test_patch_invalid_inn_changes_nothing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -871,3 +896,23 @@ async def test_confirm_undated_form_requires_period( assert edit.status_code == HTTPStatus.OK, edit.text assert edit.json()["document"]["period_year"] == 2024 # noqa: PLR2004 - год даты из правки assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + + +async def test_status_with_lines_but_no_columns_is_stable_after_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path +) -> None: + """M-2: «есть строки» — наличие строк формы (как при маппинге), а не наличие сумм: форма со строками без колонок + остаётся `RECOGNIZED` и после правки шапки.""" + data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8")) + data["documents"][0]["columns"] = [] + for line in data["documents"][0]["lines"]: + line["values"] = [] + path = tmp_path / "no_columns.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + entity = await rostov_entity(seeded, path) + (document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None) + assert document.status is DocumentStatus.RECOGNIZED + client = await auth_client(KA_RF) + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED" -- 2.47.2 From 4a5cb87e63a9a1d96f65d6f1c3e6bdb2f7b038f4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:35:12 +0400 Subject: [PATCH 33/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9620=E2=80=9322=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=203b;=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20=C2=AB=D0=A4=D0=BE=D1=80=D0=BC=D0=B0=20=D1=81=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA=C2=BB=20=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=9F=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=C2=BB=20(M-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 3 +++ .../slice-3b-statements-api/specs/documents-api/spec.md | 2 +- .../slice-3b-statements-api/specs/osv-result-mapping/spec.md | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index b97b3fd..c6ceb45 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -179,3 +179,6 @@ seed-dev` с нуля. | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | | 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей. **Закрыто (fix wave, T8.2): комментарии удаляются вместе с документами** в `delete_file_rows` — удаление файла и так обязано чистить зависимые от документов строки (формы ББ/ОФР, C-1), лишний `DELETE` дёшев, а осиротевшие строки не нужны никому | | 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула | +| 20 | 2026-10-07 | R-11: цель проверки §7 присутствует в колонке, только если сумма в этой колонке не `NULL`; пустая ячейка цели — соотношение пропускается (как в tabular-parser при отсутствии значения), отсутствующий член правой части = 0 | ядро создаёт ячейку на каждую пару «строка × колонка» (D-2), по БД «нет записи значения» и «запись с `NULL`» неразличимы; иначе пустая ячейка итога давала бы ложное жёсткое нарушение | у tabular-parser запись значения с `amount = null` считается присутствующей (0); в ядре такая ячейка — отсутствующая | +| 21 | 2026-10-07 | R-12: `PATCH header.inn = null` → 422 `INVALID_INN` (очистить ИНН нельзя) | иначе очистка ИНН переключала бы `counterparty_check` на сверку по имени и обходила `MISMATCH`; спека: «10 или 12 цифр, иначе 422» | аналитик не может стереть мусорный ИНН — только заменить корректным | +| 22 | 2026-10-07 | R-13: при повторе `column_index` в `columns[]` результата или повторе пары (строка, колонка) в `values[]` оставляется первое, пишется предупреждение маппинга (документ `NEEDS_CHECK`) | контракт повтор не запрещает; без этого `IntegrityError` ронял маппинг всего файла (I-1 финального ревью) | потеря второго значения при ошибке парсера (виден в предупреждении) | diff --git a/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md index 4ccb557..3b7bccb 100644 --- a/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md +++ b/openspec/changes/slice-3b-statements-api/specs/documents-api/spec.md @@ -69,7 +69,7 @@ #### Scenario: Форма с нарушением проверок - **WHEN** подтверждается `BALANCE` с непройденным жёстким соотношением без акцепта -- **THEN** 409 `CHECKS_NOT_PASSED`, в `details` — нарушение; после акцепта подтверждение проходит +- **THEN** 409 `CHECKS_NOT_PASSED`, в `detail.failures` — нарушение; после акцепта подтверждение проходит #### Scenario: Форма без периода - **WHEN** подтверждается форма из OCR, у которой нет ни даты отчёта, ни дат колонок diff --git a/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md index 133535e..aef056b 100644 --- a/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md +++ b/openspec/changes/slice-3b-statements-api/specs/osv-result-mapping/spec.md @@ -36,8 +36,8 @@ LOCKED`), проверять, что `result_s3_object` относится к б - **THEN** файл `PROCESSED`, создан документ `BALANCE`, ошибки «нет документов ОСВ» нет #### Scenario: Пустой результат -- **WHEN** результат валиден по схеме, но `documents = []` -- **THEN** файл `ERROR` с `metrics.mapping_error = "в результате нет документов"`, повтор не выполняется +- **WHEN** результат без документов (`documents = []`): контракт v1.x такого результата не допускает — его останавливает проверка схемы, а прямой вызов `map_file` даёт `MappingFatal` +- **THEN** файл `ERROR`, причина в `metrics.mapping_error` (при проверке схемы — ошибка схемы, в `map_file` — «в результате нет документов»), повтор не выполняется ## ADDED Requirements -- 2.47.2 From 51d322c8a79fff648070b736446f5e0a30bd956c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:48:03 +0400 Subject: [PATCH 34/36] =?UTF-8?q?fix(statements):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=20M-1=20=E2=80=94=20=D0=BF=D1=83=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=20=D1=86=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=BD=D1=83=D0=BB=D1=91=D0=BC,=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=B2=20tabular-parser;=20=D0=BE=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8F=20inn=20(N-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/statement_checks.py | 14 ++++++++------ api/src/schemas/statements.py | 2 +- api/tests/test_domain_statement_checks.py | 16 ++++++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index 81e97ca..3c6dca8 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -1,10 +1,11 @@ """Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5). Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если -в колонке есть его цель (есть сумма: пустая ячейка — цели нет); отсутствующий код и пустая сумма в правой части — 0; -правая часть считается как записано и с `abs_terms` по модулю, выполнено, если хоть один вариант в пределах -`tolerance`; жёсткое нарушение не больше `rounding_tolerance_percent` от наибольшего по модулю числа соотношения — -мягкое; «сумма пятизначных подстрок = четырёхзначный родитель» — мягкая. Результат не хранится (DBML). +в форме есть строка его цели (пустая сумма `None` = 0: оба производителя отдают значение на каждую колонку каждой +строки); отсутствующий код и пустая сумма в правой части — 0; правая часть считается как записано и с `abs_terms` по +модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше +`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок = +четырёхзначный родитель» — мягкая. Результат не хранится (DBML). """ import functools @@ -185,8 +186,9 @@ def run_checks( hard: list[FailedCheck] = [] soft: list[FailedCheck] = [] for column_index in column_indexes: - # код присутствует в колонке, только если в ней есть сумма (пустая ячейка — кода нет, как в tabular-parser) - amounts = {code: amount for code, values in lines if (amount := values.get(column_index)) is not None} + # присутствие цели = наличие строки формы; пустая сумма (None) = 0, как `_amounts` в tabular-parser: оба + # производителя отдают значение на каждую колонку каждой кодированной строки (пустая ячейка — amount=None) + amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines} for rule in [*explicit, *sublines]: if rule not in sublines and rule.target not in amounts: continue diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 614ff5f..a359f71 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -246,7 +246,7 @@ class HeaderPatch(BaseModel): model_config = ConfigDict(extra="forbid") - inn: str | None = None + inn: str | None = Field(None, description="10 или 12 цифр; null не допускается (422 INVALID_INN)") report_date: datetime.date | None = None units: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)] | None = None diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 1838024..295de02 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -197,10 +197,14 @@ def test_broken_rules_file(tmp_path) -> None: load_check_rules(bad) -def test_target_with_empty_cell_in_column_is_skipped_but_zero_is_checked() -> None: - """M-1: цель присутствует в колонке, только если в ней есть сумма: пустая ячейка (`None`) — цели нет, соотношение - пропущено (как в tabular-parser); явный 0 — цель есть, и при ненулевых слагаемых это нарушение.""" +def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None: + """M-1 (откат): цель присутствует, если строка есть в форме; пустая ячейка (`None`) = 0 — как в tabular-parser + (`_amounts`, null -> 0): оба производителя отдают значение на каждую колонку каждой кодированной строки, пустая + или нераспознанная ячейка итога при ненулевых слагаемых — жёсткое нарушение (а не пропуск правила).""" terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"})) - empty = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) - failed = _by_relation(empty.failed, "1600 = 1100 + 1200") - assert [(item.column_index, item.expected, item.actual) for item in failed] == [(1, D("0"), D("900"))] + report = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES) + failed = _by_relation(report.failed, "1600 = 1100 + 1200") + assert [(item.column_index, item.expected, item.actual) for item in failed] == [ + (0, D("0"), D("900")), + (1, D("0"), D("900")), + ] -- 2.47.2 From 669719467f2ffd001b7c141e6ea0dff48a9581d3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:53:26 +0400 Subject: [PATCH 35/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=E2=84=9620=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=203b=20=E2=80=94=20=D0=BE=D1=82=D0=BE=D0=B7=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=20(=D0=BF=D1=80=D0=B8=D1=81=D1=83=D1=82?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B5=20=D1=86=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=C2=A77=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D0=BE=D1=81=D1=8C=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B2=20tabular-parser)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index c6ceb45..1dead31 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -179,6 +179,6 @@ seed-dev` с нуля. | 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — | | 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей. **Закрыто (fix wave, T8.2): комментарии удаляются вместе с документами** в `delete_file_rows` — удаление файла и так обязано чистить зависимые от документов строки (формы ББ/ОФР, C-1), лишний `DELETE` дёшев, а осиротевшие строки не нужны никому | | 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула | -| 20 | 2026-10-07 | R-11: цель проверки §7 присутствует в колонке, только если сумма в этой колонке не `NULL`; пустая ячейка цели — соотношение пропускается (как в tabular-parser при отсутствии значения), отсутствующий член правой части = 0 | ядро создаёт ячейку на каждую пару «строка × колонка» (D-2), по БД «нет записи значения» и «запись с `NULL`» неразличимы; иначе пустая ячейка итога давала бы ложное жёсткое нарушение | у tabular-parser запись значения с `amount = null` считается присутствующей (0); в ядре такая ячейка — отсутствующая | +| 20 | 2026-10-07 | R-11 (отозван): предложенное финальным ревью изменение «цель §7 присутствует в колонке, только если сумма не `NULL`» отклонено и откатано; присутствие цели — наличие строки в форме, пустая сумма (`NULL`) = 0, как `_amounts` в tabular-parser | оба производителя (tabular-parser `lines.py`, scan-gateway `normalizer/ocr/document.py`) отдают значение на каждую колонку каждой кодированной строки, пустая ячейка — `amount = null`; парсер считает такую запись присутствующей, при ненулевых слагаемых даёт жёсткое нарушение; иное поведение отключало бы проверку итога правкой `amount = null` в обход акцепта | — (поведение не менялось; тест фиксирует нарушение при пустой цели) | | 21 | 2026-10-07 | R-12: `PATCH header.inn = null` → 422 `INVALID_INN` (очистить ИНН нельзя) | иначе очистка ИНН переключала бы `counterparty_check` на сверку по имени и обходила `MISMATCH`; спека: «10 или 12 цифр, иначе 422» | аналитик не может стереть мусорный ИНН — только заменить корректным | | 22 | 2026-10-07 | R-13: при повторе `column_index` в `columns[]` результата или повторе пары (строка, колонка) в `values[]` оставляется первое, пишется предупреждение маппинга (документ `NEEDS_CHECK`) | контракт повтор не запрещает; без этого `IntegrityError` ронял маппинг всего файла (I-1 финального ревью) | потеря второго значения при ошибке парсера (виден в предупреждении) | -- 2.47.2 From 1b20d576167141f4be6d5727badb13eab015169c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 15:05:17 +0400 Subject: [PATCH 36/36] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9623=E2=80=9325=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=203b=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D1=81=D1=81=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D1=82=D0=B0,=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B0=20(OQ-154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-3b-statements-api/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/slice-3b-statements-api/design.md b/openspec/changes/slice-3b-statements-api/design.md index 1dead31..a892139 100644 --- a/openspec/changes/slice-3b-statements-api/design.md +++ b/openspec/changes/slice-3b-statements-api/design.md @@ -182,3 +182,6 @@ seed-dev` с нуля. | 20 | 2026-10-07 | R-11 (отозван): предложенное финальным ревью изменение «цель §7 присутствует в колонке, только если сумма не `NULL`» отклонено и откатано; присутствие цели — наличие строки в форме, пустая сумма (`NULL`) = 0, как `_amounts` в tabular-parser | оба производителя (tabular-parser `lines.py`, scan-gateway `normalizer/ocr/document.py`) отдают значение на каждую колонку каждой кодированной строки, пустая ячейка — `amount = null`; парсер считает такую запись присутствующей, при ненулевых слагаемых даёт жёсткое нарушение; иное поведение отключало бы проверку итога правкой `amount = null` в обход акцепта | — (поведение не менялось; тест фиксирует нарушение при пустой цели) | | 21 | 2026-10-07 | R-12: `PATCH header.inn = null` → 422 `INVALID_INN` (очистить ИНН нельзя) | иначе очистка ИНН переключала бы `counterparty_check` на сверку по имени и обходила `MISMATCH`; спека: «10 или 12 цифр, иначе 422» | аналитик не может стереть мусорный ИНН — только заменить корректным | | 22 | 2026-10-07 | R-13: при повторе `column_index` в `columns[]` результата или повторе пары (строка, колонка) в `values[]` оставляется первое, пишется предупреждение маппинга (документ `NEEDS_CHECK`) | контракт повтор не запрещает; без этого `IntegrityError` ронял маппинг всего файла (I-1 финального ревью) | потеря второго значения при ошибке парсера (виден в предупреждении) | +| 23 | 2026-10-07 | R-10: пакеты доработок A (тесты, PB.1/PB.2) и B отдельного task-ревью не проходили так же, как задачи (B — ревью получил, A — мутационная проверка 4 тестов) | покрываются финальным ревью ветки | пропуск слабого теста до финального ревью (финальное ревью прошло) | +| 24 | 2026-10-07 | R-14: откат M-1 (коммиты 51d322c, 6697194) отдельного ревью не проходил | код `statement_checks.py` побайтно возвращён к состоянию до e851445 (только docstring и комментарии), откат предписан повторным opus-ревью, тест фиксирует поведение | ошибка в новом тесте или docstring | +| 25 | 2026-10-07 | Автозамена файла для форм ББ/ОФР работает на уровне файла (ключ документа: тип, NULL-счёт, год, квартал): исправленный отдельный ОФР заменит прежнюю книгу «ББ + ОФР» того же периода, подтверждённый ББ из неё отвяжется от версий «в работе» | поведение ОСВ (Срезы 2c/4) распространено на формы; замена по документам потребовала бы менять модель замены | замена формы вместе с соседней; вынесено владельцу — OQ-154 | -- 2.47.2