From 3623358fb4f28909db4db2bc5f31085e483193a8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 16:36:48 +0400 Subject: [PATCH 01/10] =?UTF-8?q?docs(openspec):=20change=20auto-confirm-c?= =?UTF-8?q?lean-documents=20=E2=80=94=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D1=85=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal, design (D-1…D-6, решения без обсуждения №1–7) и дельты osv-result-mapping, primary-files, audit-trail: чистый документ при маппинге сразу CONFIRMED (система, confirmed_by NULL; переход версии — от загрузившего), удаление автоподтверждённых файлов до DATA_CORRECTION включительно. Решение владельца 08.10.2026, ТЗ 03_9 п. 4. Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 2 + .../auto-confirm-clean-documents/design.md | 111 ++++++++++++++ .../auto-confirm-clean-documents/proposal.md | 55 +++++++ .../specs/audit-trail/spec.md | 28 ++++ .../specs/osv-result-mapping/spec.md | 140 ++++++++++++++++++ .../specs/primary-files/spec.md | 36 +++++ 6 files changed, 372 insertions(+) create mode 100644 openspec/changes/auto-confirm-clean-documents/.openspec.yaml create mode 100644 openspec/changes/auto-confirm-clean-documents/design.md create mode 100644 openspec/changes/auto-confirm-clean-documents/proposal.md create mode 100644 openspec/changes/auto-confirm-clean-documents/specs/audit-trail/spec.md create mode 100644 openspec/changes/auto-confirm-clean-documents/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/auto-confirm-clean-documents/specs/primary-files/spec.md diff --git a/openspec/changes/auto-confirm-clean-documents/.openspec.yaml b/openspec/changes/auto-confirm-clean-documents/.openspec.yaml new file mode 100644 index 0000000..01660ba --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-08 diff --git a/openspec/changes/auto-confirm-clean-documents/design.md b/openspec/changes/auto-confirm-clean-documents/design.md new file mode 100644 index 0000000..6621e0d --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/design.md @@ -0,0 +1,111 @@ +# Design: автоподтверждение чистых документов + +## Context + +Маппинг результата (`services/result_mapping.py`, `_map_candidate`) в одной транзакции: `map_file` создаёт документы +файла со статусом `RECOGNIZED` или `NEEDS_CHECK` (ОСВ — `domain/osv_mapping.document_status`, §9.3; формы — +`domain/statement_mapping`, §9.5 D-6; `DISCLOSURE`/`OTHER` — всегда `RECOGNIZED`), автозамена снимает привязки +документов прежних файлов (`on_documents_superseded`), затем хук `on_documents_mapped` переводит версии в `DATA_CORRECTION` +по документам `NEEDS_CHECK` (`changed_by = file.uploaded_by`, design №2 Среза 4). Привязки к версиям создаёт только +ручное подтверждение (`DocumentService.confirm` → `on_document_confirmed`) и подхват при создании версии и правке +периметра (`bind_confirmed`). Удаление файла (`PrimaryFilesService.delete`) запрещено при любом `CONFIRMED` документе. + +Порядок блокировок (`CLAUDE.md` arm-core): задание → файл → документы → advisory ГК → версии по `id`. Маппинг уже +держит задание, файл и документы, когда вызывает хуки версий. + +## Goals / Non-Goals + +**Goals:** чистый документ сразу `CONFIRMED` и в своде без действий аналитика; поведение привязки и статусов версии — +то же, что при ручном подтверждении; ошибочно загруженный чистый файл можно удалить, пока версия не ушла дальше +корректировки. + +**Non-Goals:** см. proposal — новые поля, UI, миграция данных, пересмотр `NEEDS_CHECK`. + +## Decisions + +### D-1. Где: шаг маппинга после автозамены +`_map_candidate`: `map_file` → автозамена (как сейчас) → **`auto_confirm(session, file, documents, now)`** → +`on_documents_mapped`. После автозамены — чтобы привязки прежних версий файла были уже сняты, а замена по ключу (R-1) +работала как при ручном подтверждении. До `on_documents_mapped` — тот смотрит только `NEEDS_CHECK`, автоподтверждённые +его не касаются. Всё в одной транзакции маппинга: при ошибке откатывается и маппинг, и подтверждение (повтор задания — +по счётчику попыток, как сейчас). + +### D-2. Кто: система за документ, загрузивший — за переход версии (решение владельца 08.10.2026) +`document.confirmed_by = NULL`, `confirmed_at = now`; у формы `financial_statement_meta.reviewed_by = NULL`, +`reviewed_at = now`. `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type = document)`: `user_id = NULL`, +`before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED`, `description = «автоподтверждение: без замечаний»` +(DBML: `audit_event.user_id` — «NULL для системных событий»). Переход версии — через `consolidation_status.transition` +с `changed_by = stored_file.uploaded_by` (`consolidation_status_history.changed_by` NOT NULL в DBML) и `comment = +«автоподтверждение документа <тип> <счёт>»`. `on_document_confirmed` получает `changed_by: uuid | None` и `comment` +от вызывающего: ручное подтверждение передаёт пользователя и `None`, автоподтверждение — `file.uploaded_by` и +комментарий; при `changed_by = None` привязка создаётся, переход пропускается с записью в лог. + +Признак автоподтверждения — `CONFIRMED` и `confirmed_by IS NULL`: ручное подтверждение всегда пишет пользователя +(`DOCUMENT_EDIT` на маршруте). + +### D-3. Что: документ, ставший `RECOGNIZED` при маппинге, и только он +Кандидаты — документы файла со статусом `RECOGNIZED` сразу после `map_file`. Для `BALANCE`/`INCOME` с шапкой +дополнительно — условия ручного подтверждения (D-9 Среза 3b): `period_year` и `period_quarter` определены, жёстких +нарушений §7 нет (на момент маппинга акцептов нет). Условия выносятся из `DocumentService._check_statement` в общую +функцию (одна точка правды; ручной путь продолжает отвечать 409 `PERIOD_REQUIRED`/`CHECKS_NOT_PASSED`, автоматический — +просто оставляет `RECOGNIZED`). По §9.5 D-6 `RECOGNIZED` формы уже требует период и пройденные проверки, так что +расхождение на практике — только защита от рассинхронизации правил. `DISCLOSURE`/`OTHER` (`RECOGNIZED` по D-3 Среза 3b) +подтверждаются тоже: проверять в них нечего, в версии они не привязываются (`fits` — ОСВ и формы). + +Пересчёт статуса формы после правки или акцепта (`RECOGNIZED`/`NEEDS_CHECK`) автоподтверждения не запускает: после +исправлений аналитик нажимает «Подтвердить данные» (ТЗ 03_9, 10_10). + +### D-4. Удаление: только автоподтверждённое и только до согласования +`PrimaryFilesService.delete` под существующими блокировками (задания → файл → документы) проверяет: если у файла есть +`CONFIRMED` документ с `confirmed_by IS NOT NULL` — 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (как сейчас). Если все +`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ (`lock_versions_for_entity` по всем статусам, где +есть привязки этих документов; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая +версия с привязкой в `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` с +текстом «документ уже участвует в версии на согласовании». Затем привязки снимаются (`consolidation_document`), файл +удаляется как сейчас; `FILE_DELETE.after_value = {unbound_version_ids: [...]}` (по возрастанию; пусто — пустой список), +`before_value` — карточка файла, как сейчас. Статус версии не меняется (свод — чистая функция, пересчитается). +`FILE_SUPERSEDES_OTHERS` — без изменений. + +### D-5. Блокировки +`auto_confirm` вызывается под заданием, файлом и документами маппинга; версии берёт `on_document_confirmed` через +`lock_versions_for_entity` (advisory ГК → версии `FOR UPDATE` по `id`) — тот же путь, что у ручного подтверждения. +Несколько документов файла — повторные вызовы в одной транзакции по одному ЮЛ: блокировки уже взяты, порядок не +нарушается. Документы файла уже под `FOR NO KEY UPDATE` маппинга (R-3 совместимость со вставкой привязки). + +### D-6. Данные и совместимость +Миграции схемы и данных нет. Старые `RECOGNIZED` остаются и подтверждаются кнопкой. API не меняется: +`DocumentOut.confirmed_by` уже `nullable`; `POST …/confirm` для автоподтверждённого отвечает 409 `ALREADY_CONFIRMED`. +Чек-лист: ячейка с автоподтверждённым документом — `CONFIRMED` («Загружен»). + +## Risks / Trade-offs + +- **Неверный, но «чистый» файл сразу в своде** (например, ОСВ не того ЮЛ с совпавшим ИНН шапки). Раньше его ловил + взгляд аналитика при подтверждении. Смягчение: удаление разрешено до согласования (D-4), автозамена новой версией + файла работает как раньше; документ виден в панели и чек-листе как «Загружен». +- **Нагрузка на транзакцию маппинга** растёт на блокировку версий ЮЛ для каждого документа — то же, что делает хук + `on_documents_mapped`; в комплекте десятки документов, не тысячи. +- **Журнал**: событие `DOCUMENT_CONFIRM` без пользователя — системное, как `FILE_REPLACE`; фильтр журнала по + пользователю его не покажет. + +## Migration Plan + +Выкатка обычная: миграций нет. После обновления ядра новые результаты подтверждаются автоматически; уже загруженные +`RECOGNIZED` — вручную (или перезагрузкой файла). + +## Open Questions + +- OQ-157 (новый, docs): автоподтверждение чистых документов — решено владельцем 08.10.2026; пересматривает OQ-05 + (`RECOGNIZED` остаётся статусом модели, но после маппинга на нём документ не задерживается) и уточняет OQ-110 + (удаление при консолидациях — для автоподтверждённых до `DATA_CORRECTION` включительно). + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-08 | Автоподтверждение — после автозамены и до `on_documents_mapped` в той же транзакции маппинга | замена привязки по ключу работает как при ручном подтверждении; откат целиком при ошибке | неверный порядок — лишняя/потерянная привязка, ловится тестом автозамены | +| 2 | 2026-10-08 | `DISCLOSURE`/`OTHER` тоже подтверждаются автоматически | в них нечего проверять; ТЗ 03_9 п. 4 не различает типы | расшифровку нельзя удалить без условий D-4 — те же, что у ОСВ | +| 3 | 2026-10-08 | Условия формы — общая функция с ручным подтверждением; при невыполнении документ остаётся `RECOGNIZED` без ошибки | одна точка правды; автоматический путь не должен падать | форма без периода ждёт кнопку, как раньше | +| 4 | 2026-10-08 | После правки/акцепта формы автоподтверждения нет | ТЗ: «Подтвердить данные» после исправлений | лишний клик после правки — осознанный | +| 5 | 2026-10-08 | Существующие `RECOGNIZED` не мигрируются | контур без продовых данных; миграция данных с аудитом без пользователя — лишний риск | на стенде старые документы подтверждаются кнопкой | +| 6 | 2026-10-08 | Комментарий перехода версии — «автоподтверждение документа <тип> <счёт>», описание аудита — «автоподтверждение: без замечаний» | история версии и журнал объясняют, откуда переход без клика | тексты меняются без миграции | +| 7 | 2026-10-08 | Удаление: проверка статусов версий только тех, к которым привязаны документы файла; `FILE_DELETE.after_value.unbound_version_ids` | симметрично `FILE_REPLACE.unbound_version_ids` | — | diff --git a/openspec/changes/auto-confirm-clean-documents/proposal.md b/openspec/changes/auto-confirm-clean-documents/proposal.md new file mode 100644 index 0000000..919db8d --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/proposal.md @@ -0,0 +1,55 @@ +# Proposal: автоподтверждение чистых документов + +**Срез:** доработка Срезов 2c, 3b и 4 (`../docs/context/09-delivery-plan.md`) — путь «загрузка → свод» без пустых кликов. +**Репозиторий:** arm-core. **Issue:** RSHB/arm-core#64. Правило и OQ — bounded-PR в `../docs` (`05-domain-rules.md` §9.3, +§9.5; `07-open-questions.md` OQ-157, пометка к OQ-05 и OQ-110; гайды `dev/qa-guide.md`, `dev/frontend-api-guide.md`). +DBML и контракты обмена не меняются. + +## Why + +Документ без замечаний сейчас получает `RECOGNIZED` и попадает в консолидацию только после ручного «Подтвердить» +(`CONFIRMED`, §3 п. 1). Для ОСВ этот клик ничего не проверяет: строки ОСВ не правятся (OQ-114), ошибка исправляется +перезагрузкой файла. На комплекте РБ + Счастливочная за 1 кв. 2025 это 16 нажатий подряд (прогон владельца 08.10.2026). +ТЗ этого шага не требует: 03_9, п. 4 загрузки — «при отсутствии замечаний документ получает статус „Загружен“, при +замечаниях — „Требуется проверка“»; 10_10 — «Загружен» при прохождении всех проверок; «Подтвердить данные» — шаг после +исправлений. Решение владельца 08.10.2026: чистый документ сразу «Загружен». + +## What Changes + +- **Автоподтверждение при маппинге.** Документ, который маппинг сделал бы `RECOGNIZED`, сразу становится `CONFIRMED`: + `confirmed_by = NULL`, `confirmed_at = now`, у формы ББ/ОФР `reviewed_by = NULL`, `reviewed_at = now`; + `audit_event(DOCUMENT_CONFIRM)` с `user_id = NULL` и `description = «автоподтверждение: без замечаний»`. Форма + подтверждается автоматически только при условиях ручного подтверждения (период определён, жёсткие проверки пройдены), + иначе остаётся `RECOGNIZED`. `DISCLOSURE`/`OTHER` — тоже сразу `CONFIRMED`. +- **Привязка и статус версии — как при ручном подтверждении.** Документ привязывается ко всем подходящим версиям «в + работе» с заменой по ключу (R-1); версия из `INITIATED`/`DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с + `changed_by = stored_file.uploaded_by` и комментарием «автоподтверждение документа …». Файл без `uploaded_by` — + привязка есть, перехода нет, запись в лог (как у хука `on_documents_mapped`, design №2 Среза 4). +- **`NEEDS_CHECK` и правки — вручную, как раньше.** Форма, ставшая чистой после правки или акцепта, подтверждается + кнопкой (ТЗ 03_9: «Подтвердить данные» после исправлений). `RECOGNIZED` остаётся в модели: старые документы и формы + после правок. +- **Удаление файла с автоподтверждёнными документами.** `DELETE /files/{id}` разрешён, если все `CONFIRMED` документы + файла подтверждены автоматически (`confirmed_by IS NULL`) и каждая версия, к которой они привязаны, в `INITIATED`, + `DOCUMENTS_PROCESSING` или `DATA_CORRECTION`; привязки снимаются, статус версии не меняется, + `FILE_DELETE.after_value.unbound_version_ids`. Иначе — 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, как сейчас. + +Таблицы DBML v5.17: `document` (`status`, `confirmed_by`, `confirmed_at`), `financial_statement_meta` (`reviewed_by`, +`reviewed_at`), `consolidation_document`, `consolidation_version`, `consolidation_status_history`, `audit_event`, +`stored_file` (`uploaded_by`). Новых полей нет. + +## Non-goals + +- Признак «подтверждено автоматически» отдельным полем или в API сверх `confirmed_by = null` — не вводится. +- Подсказка в UI arm-web «подтверждено системой», кнопка удаления файла в UI — вне объёма (arm-web). +- Миграция данных: существующие `RECOGNIZED` документы не подтверждаются задним числом. +- Повторный автозапуск после правки формы, межформенная проверка как условие — нет (мягкая, §7). +- Изменение правил `NEEDS_CHECK` (OQ-116, предупреждения маппинга) — нет. + +## Impact + +- Код: `services/result_mapping.py` (`_map_candidate`), `services/consolidation_documents.py` + (`on_document_confirmed` с внешним `changed_by`/`comment`, новый `auto_confirm`), `services/documents.py` (общая + проверка условий формы), `services/primary_files.py` (`delete`), `repositories/files.py` (снятие привязок). +- Спеки: `osv-result-mapping`, `documents-api`, `primary-files`, `audit-trail`. +- Поведение для фронта: чистые документы приходят сразу `CONFIRMED` («Загружен»); `confirmed_by = null` — признак + автоподтверждения. diff --git a/openspec/changes/auto-confirm-clean-documents/specs/audit-trail/spec.md b/openspec/changes/auto-confirm-clean-documents/specs/audit-trail/spec.md new file mode 100644 index 0000000..3a442a4 --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/specs/audit-trail/spec.md @@ -0,0 +1,28 @@ +# Spec Delta: audit-trail + +## MODIFIED Requirements + +### Requirement: События файлов и документов +Загрузка, автозамена, удаление файла и подтверждение документа SHALL писать `audit_event` через сервис аудита: + +| Действие | `action` | `section` | `object_type` | `user_id` | +|---|---|---|---|---| +| файл принят | `FILE_UPLOAD` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор | +| прежний файл заменён новым (автозамена после маппинга) | `FILE_REPLACE` | `PRIMARY_DOCUMENTS` | `stored_file` (заменённый) | NULL (система; инициатор загрузки — в `description`) | +| файл удалён | `FILE_DELETE` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор | +| документ подтверждён пользователем | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | инициатор | +| документ подтверждён автоматически при маппинге (чистый) | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | NULL (система; `description` = «автоподтверждение: без замечаний») | + +`FILE_REPLACE` SHALL содержать в `after_value` новый `file_id`, в `description` — перечень заменённых документов и +`confirmed=true`, если среди них был `CONFIRMED`. `FILE_DELETE` SHALL содержать в `after_value.unbound_version_ids` +версии, где сняты привязки удалённых автоподтверждённых документов. Смена статусов задания и файла в обмене и в цикле +маппинга `audit_event` не пишет (`ASSUMPTION(OQ-55)`). + +#### Scenario: Загрузка двух файлов +- **WHEN** принято два файла одним запросом +- **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`) + +#### Scenario: Автоподтверждение +- **WHEN** смаплен чистый результат ОСВ +- **THEN** одно событие `DOCUMENT_CONFIRM` с `object_id` документа, `user_id` NULL, `before_value.status = RECOGNIZED`, + `after_value.status = CONFIRMED`, `description = «автоподтверждение: без замечаний»` diff --git a/openspec/changes/auto-confirm-clean-documents/specs/osv-result-mapping/spec.md b/openspec/changes/auto-confirm-clean-documents/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..ca6f48b --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/specs/osv-result-mapping/spec.md @@ -0,0 +1,140 @@ +# Spec Delta: osv-result-mapping + +## ADDED Requirements + +### Requirement: Автоподтверждение чистого документа +В транзакции маппинга после автозамены прежних версий файла и до перехода версий по `NEEDS_CHECK` цикл маппинга SHALL +перевести каждый документ файла, получивший при маппинге статус `RECOGNIZED`, в `CONFIRMED` (ТЗ 03_9 п. 4, 10_10; +решение владельца 08.10.2026, OQ-157): `confirmed_by = NULL`, `confirmed_at = now`; у формы `BALANCE`/`INCOME` — +`financial_statement_meta.reviewed_by = NULL`, `reviewed_at = now`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, +object_type = document)` с `user_id = NULL`, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED` и +`description = «автоподтверждение: без замечаний»`. Форма `BALANCE`/`INCOME` с шапкой SHALL подтверждаться +автоматически, только если выполнены условия ручного подтверждения (`period_year` и `period_quarter` определены, нет +непройденных неакцептованных жёстких проверок §7); иначе она MUST оставаться `RECOGNIZED` без ошибки. Документы +`NEEDS_CHECK` MUST NOT подтверждаться автоматически. + +Автоподтверждённый документ SHALL привязываться к версиям так же, как при ручном подтверждении (`documents-api` +«Подтверждение документа»: все подходящие версии «в работе», замена привязки того же ключа R-1), и каждая версия, +получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` с `changed_by = +stored_file.uploaded_by` и `comment = «автоподтверждение документа »` (счёт — только у +ОСВ). Если `uploaded_by` NULL, привязка SHALL создаваться, а переход — пропускаться с записью в лог. Пересчёт статуса +формы после правки или акцепта MUST NOT подтверждать документ автоматически. + +#### Scenario: Чистая ОСВ сразу «Загружен» +- **WHEN** смаплен результат `fixtures/osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` для ЮЛ АО «Русский бисквит» + (задание `done`, шапка совпадает с владельцем, нарушений и предупреждений нет) +- **THEN** документ `OSV` 60 — `CONFIRMED`, `confirmed_by` NULL, `confirmed_at` заполнен; есть `audit_event(DOCUMENT_CONFIRM)` + с `user_id` NULL и описанием «автоподтверждение: без замечаний» + +#### Scenario: Привязка и переход версии +- **WHEN** версия ГК «Русский Бисквит» 2025 Q1 в `INITIATED`, РБ в периметре; смаплен чистый результат ОСВ 60 РБ за 2025 Q1, + файл загрузил `ka_go` +- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории + `INITIATED → DATA_CORRECTION` с `changed_by` = `ka_go` и комментарием «автоподтверждение документа OSV 60» + +#### Scenario: Документ с замечанием ждёт аналитика +- **WHEN** файл ОСВ загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"` +- **THEN** документ `NEEDS_CHECK`, `confirmed_at` пуст, события `DOCUMENT_CONFIRM` нет, привязки нет + +#### Scenario: Чистая форма Excel +- **WHEN** смаплен `statements-xlsx/mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, период определён) +- **THEN** документ `INCOME` `CONFIRMED`, `reviewed_by` NULL, `reviewed_at` заполнен + +#### Scenario: Форма OCR не подтверждается сама +- **WHEN** смаплен эталон `ocr/expected_normalize/БО 1 кв 2025` (уверенность значений < 100, дата отчёта не разобрана) +- **THEN** документы `BALANCE` и `INCOME` — `NEEDS_CHECK`, не `CONFIRMED` + +#### Scenario: Файл без загрузившего +- **WHEN** версия в `INITIATED`, ЮЛ в периметре; смаплен чистый результат файла с `uploaded_by` NULL +- **THEN** документ `CONFIRMED` и привязан к версии; версия осталась `INITIATED`; в логе — строка о пропуске перехода + +#### Scenario: Автозамена чистой версией файла +- **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа + (чистый), A заменён автозаменой +- **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии + +## MODIFIED Requirements + +### Requirement: Проверки шапки и статус документа +`counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки +совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке. +`period_check` SHALL быть `UNKNOWN` до появления версии консолидации (Срез 4). Для `period_quarter ∈ {Q2, Q3, Q4}` с +`is_cumulative = false` ядро SHALL сверить сальдо на начало корневой строки с документом `Q1` того же года, ЮЛ и +счёта (файл не `SUPERSEDED`) и при совпадении (±0.01) установить `is_cumulative = true` с предупреждением +(`ASSUMPTION(OQ-02)`). Статус документа при маппинге SHALL быть `RECOGNIZED`, если задание `done`, `checks` без +нарушений, `counterparty_check ≠ MISMATCH` и нет предупреждений маппинга; иначе `NEEDS_CHECK` (OQ-05). Документ +`RECOGNIZED` в той же транзакции SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»). + +#### Scenario: Шапка другой организации +- **WHEN** файл загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"` +- **THEN** `counterparty_check = MISMATCH`, `status = NEEDS_CHECK` + +#### Scenario: Нарастающая по данным +- **WHEN** у ЮЛ есть документ ОСВ 60 за 2024 Q1 (`opening_*` корня = X) и маппится ОСВ 60 «за 3 квартал 2024» с `opening_*` корня = X +- **THEN** `is_cumulative = true`, предупреждение «нарастающая по данным Q1» + +#### Scenario: Чистый результат +- **WHEN** задание `done`, шапка совпадает с владельцем, нарушений нет +- **THEN** после маппинга `status = CONFIRMED` (автоподтверждение), `counterparty_check = MATCH`, `period_check = UNKNOWN` + +### Requirement: Документ NEEDS_CHECK переводит версию в корректировку +После фиксации документов файла и автоподтверждения чистых документов цикл маппинга SHALL для каждой версии в +`DOCUMENTS_PROCESSING`, в периметр которой входит ЮЛ файла, проверить документы файла: если среди них есть документ +`NEEDS_CHECK`, подходящий версии по периоду (правила привязки `documents-api`), версия SHALL перейти в `DATA_CORRECTION` +с `changed_by = stored_file.uploaded_by` в той же транзакции маппинга (версии блокируются `FOR UPDATE` по `id` после +файла). Если `uploaded_by` NULL, переход SHALL пропускаться с записью в лог. Привязка `consolidation_document` для +документа `NEEDS_CHECK` при маппинге MUST NOT создаваться (привязку при маппинге создаёт только автоподтверждение). + +#### Scenario: Предупреждение маппинга двигает статус +- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK` +- **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет + +#### Scenario: Документ RECOGNIZED +- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным чистым документом за 2024 Q3 +- **THEN** документ `CONFIRMED` и привязан; переход версии в `DATA_CORRECTION` записан автоподтверждением (комментарий + «автоподтверждение документа …»), этот хук второго перехода не делает + +#### Scenario: Документ не подходит версии +- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным документом `NEEDS_CHECK` за 2025 Q1 +- **THEN** статус версии не изменился + +### 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`. Документ +`RECOGNIZED` после маппинга SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»); +`RECOGNIZED`, полученный пересчётом после правки или акцепта, — только вручную. Документ формы `NEEDS_CHECK` SHALL +участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK переводит версию в +корректировку»). + +#### Scenario: Чистая форма Excel +- **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла) +- **THEN** документ `INCOME` `CONFIRMED` (автоподтверждение) + +#### 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` нет + +#### Scenario: Чистая после правки — вручную +- **WHEN** форма `NEEDS_CHECK` исправлена аналитиком (`PATCH …/statement`) и пересчёт дал `RECOGNIZED` +- **THEN** документ остаётся `RECOGNIZED` до `POST …/confirm` + +### Requirement: Повтор формы в файле и прочие документы +Второй документ `BALANCE` или `INCOME` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ — +`NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без +данных (`locator` — все `locator` этого типа через запятую, статус при маппинге `RECOGNIZED`, затем автоподтверждение → +`CONFIRMED`). Форма `BALANCE`/`INCOME` без строк SHALL создаваться с шапкой и колонками (если колонки есть в результате). + +#### Scenario: Расшифровки +- **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`) +- **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую; `DISCLOSURE` — `CONFIRMED` + +#### Scenario: Форма без колонки кодов +- **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0) +- **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате) diff --git a/openspec/changes/auto-confirm-clean-documents/specs/primary-files/spec.md b/openspec/changes/auto-confirm-clean-documents/specs/primary-files/spec.md new file mode 100644 index 0000000..7a7ba6b --- /dev/null +++ b/openspec/changes/auto-confirm-clean-documents/specs/primary-files/spec.md @@ -0,0 +1,36 @@ +# Spec Delta: primary-files + +## MODIFIED Requirements + +### Requirement: Удаление файла +`DELETE /api/v1/files/{file_id}` (`DOCUMENT_EDIT`, policy) SHALL удалить строки ОСВ, документы, задания и `stored_file`, +проставить `s3_object.expires_at = now()` (объект физически не удаляется, OQ-22) и записать `audit_event(FILE_DELETE, +before_value = карточка файла, after_value = {unbound_version_ids})`. Удаление MUST быть отклонено 409 +`FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть документ `CONFIRMED`, подтверждённый пользователем (`confirmed_by` не +NULL), или если автоподтверждённый документ файла (`confirmed_by` NULL) привязан к версии в статусе, отличном от +`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` (OQ-157, уточняет OQ-110); и 409 `FILE_SUPERSEDES_OTHERS`, если на +файл ссылаются `superseded_by_file_id`. При удалении файла с автоподтверждёнными документами их привязки +`consolidation_document` SHALL сниматься, статус версий MUST NOT меняться, `after_value.unbound_version_ids` — id версий, +где привязки сняты (по возрастанию; пустой список, если привязок не было). Блокировки: задания → файл → документы → +advisory-блокировка привязок ГК → версии по `id`. Файл `SUPERSEDED` удалить можно. + +#### Scenario: Удаление ошибочного файла +- **WHEN** файл в `ERROR` удаляется владельцем +- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)` + с `after_value.unbound_version_ids = []` + +#### Scenario: Подтверждённый документ +- **WHEN** удаляется файл с документом `CONFIRMED`, у которого `confirmed_by` = пользователь +- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось + +#### Scenario: Автоподтверждённый документ в корректировке +- **WHEN** версия 2025 Q1 в `DATA_CORRECTION`, к ней привязан автоподтверждённый документ ОСВ 60 РБ; файл удаляется +- **THEN** 204; привязка снята, версия осталась в `DATA_CORRECTION`; `FILE_DELETE.after_value.unbound_version_ids` = [id версии] + +#### Scenario: Версия ушла на согласование +- **WHEN** автоподтверждённый документ файла привязан к версии в `CONSOLIDATION_RF` +- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, привязка и файл на месте + +#### Scenario: Автоподтверждённый без привязок +- **WHEN** удаляется файл, все документы которого автоподтверждены и ни к одной версии не привязаны +- **THEN** 204; `after_value.unbound_version_ids = []` -- 2.47.2 From d9324f2a3846ff89843abac9ae33b4f7ec6bba0c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 16:43:07 +0400 Subject: [PATCH 02/10] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D1=85=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 задач: хук подтверждения с автором перехода, form_confirmable, auto_confirm, вызов в маппинге, удаление файла, документация arm-core, правило и OQ-157 в docs. Co-Authored-By: Claude Opus 5.5 --- ...2026-10-08-auto-confirm-clean-documents.md | 1189 +++++++++++++++++ 1 file changed, 1189 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md diff --git a/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md b/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md new file mode 100644 index 0000000..ca4d394 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md @@ -0,0 +1,1189 @@ +# Автоподтверждение чистых документов — план реализации + +> **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:** документ, который маппинг сделал бы `RECOGNIZED`, сразу становится `CONFIRMED` («Загружен») от имени системы, привязывается к версиям и двигает их как при ручном подтверждении; файл с автоподтверждёнными документами можно удалить, пока версии не ушли дальше `DATA_CORRECTION`. + +**Architecture:** новый модуль `services/auto_confirm.py` вызывается в транзакции маппинга (`result_mapping._map_candidate`) после `map_file` (а значит после автозамены) и до `on_documents_mapped`; он переиспользует хук ручного подтверждения `consolidation_documents.on_document_confirmed`, которому теперь передают `changed_by`/`comment`. Условия формы ББ/ОФР — общая функция `services/statements.form_confirmable`. Удаление файла (`primary_files.delete`) снимает привязки автоподтверждённых документов через новые функции `consolidation_documents.bound_versions` / `unbind_documents`. Правило и OQ — отдельный PR в arm-ka-docs. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, PostgreSQL 16, pytest-asyncio; OpenSpec; Gitea `tea`. + +**Spec:** `openspec/changes/auto-confirm-clean-documents/` (proposal.md, design.md, specs/osv-result-mapping, specs/primary-files, specs/audit-trail). Issue RSHB/arm-core#64. + +**Рабочее место:** зеркало `/Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/`: `arm-core/` — worktree ветки `feat/64-auto-confirm`, `docs/` — worktree ветки `feat/64-auto-confirm-rules`, `fixtures`, `arm-web`, `CLAUDE.md`, `.claude` — симлинки. Команды `make` — из `.wt/auto-confirm/arm-core`, `pytest` — из `.wt/auto-confirm/arm-core/api` (`.venv/bin/pytest`; venv создаётся `make venv`, если его нет). Интеграционные тесты требуют PostgreSQL/NATS/MinIO стенда (`docker ps` — контейнеры `arm-ka-*`); на стенде работает ядро владельца на :8000 — его НЕ останавливать, тесты используют свою БД `armcore_pytest_`. `codegraph_explore` — с `projectPath=/Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core`. + +## Global Constraints + +- DBML v5.17 не меняется; новых полей и миграций нет (`confirmed_by` уже nullable, `audit_event.user_id` — «NULL для системных событий»). +- Автор автоподтверждения: `document.confirmed_by = NULL`, `financial_statement_meta.reviewed_by = NULL`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type='document')` с `user_id = NULL`, `description = «автоподтверждение: без замечаний»`, `before_value = {"status": "RECOGNIZED"}`, `after_value = {"status": "CONFIRMED"}`. +- Переход версии при автоподтверждении: `changed_by = stored_file.uploaded_by`, `comment = «автоподтверждение документа »` (счёт — только если есть, через пробел; например «автоподтверждение документа OSV 60», «автоподтверждение документа INCOME»). `uploaded_by IS NULL` → привязка есть, перехода нет, строка в лог. +- Автоматически подтверждается только документ со статусом `RECOGNIZED` сразу после `map_file`; `NEEDS_CHECK` — никогда; форма — только если `form_confirmable` (период определён, жёстких нарушений §7 нет). +- Порядок блокировок: задание → файл → документы → advisory ГК → версии по `id` (`CLAUDE.md` arm-core «Консолидации», правило 2). +- Удаление: `confirmed_by IS NOT NULL` у любого `CONFIRMED` документа файла → 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; привязка автоподтверждённого документа к версии вне `IN_WORK_STATUSES` (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`) → 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; иначе привязки снимаются, `FILE_DELETE.after_value = {"unbound_version_ids": [...]}` (UUID по возрастанию; `[]` если нет). +- Язык комментариев, docstring, коммитов — русский; номер § правил — в docstring; сообщения коммитов заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 `. +- Перед PR — `make check-full` (из `.wt/auto-confirm/arm-core`); вывод — в описание PR. + +## Review Focus + +1. **Файл с документами `RECOGNIZED` и `NEEDS_CHECK` вперемешку** (книга «ББ чистый + ОФР с нарушением») — чистый подтверждается и привязывается, `NEEDS_CHECK` остаётся и двигает версию из `DOCUMENTS_PROCESSING` хуком `on_documents_mapped`; версия получает ровно один переход (второй хук видит уже `DATA_CORRECTION`). Тест — Task 4, `test_mixed_file_confirms_clean_and_moves_version_once`. +2. **Автозамена: новый чистый файл того же ключа** — привязка документа старого файла снимается, новый документ `CONFIRMED` и привязан к той же версии, в версии нет двух привязок одного ключа. Тест — Task 4, `test_supersede_rebinds_auto_confirmed_document`. +3. **Повтор маппинга того же задания после сбоя** (транзакция откатилась, `mapping_attempts + 1`) — автоподтверждение откатывается вместе с маппингом, при повторе нет двойных событий `DOCUMENT_CONFIRM`. Тест — Task 4, `test_auto_confirm_rolls_back_with_mapping`. +4. **Ручное подтверждение уже автоподтверждённого документа** (фронт со старым статусом в кэше жмёт «Подтвердить») — 409 `ALREADY_CONFIRMED`, ничего не меняется. Тест — Task 3, `test_manual_confirm_of_auto_confirmed_is_409`. +5. **Удаление файла, чей автоподтверждённый документ привязан к двум версиям, одна из которых ушла на согласование** — 409, ни одна привязка не снята, файл на месте. Тест — Task 5, `test_delete_blocked_if_any_bound_version_left_in_work`. + +--- + +### Task 1: Хук подтверждения принимает автора перехода и комментарий + +**Files:** +- Modify: `api/src/services/consolidation_documents.py` (`on_document_confirmed`, ~строки 200–240) +- Modify: `api/src/services/documents.py` (`DocumentService.confirm`, вызов хука, ~строка 275) +- Test: `api/tests/test_consolidation_documents.py` + +**Interfaces:** +- Produces: `async def on_document_confirmed(session: AsyncSession, audit: AuditService, *, document: Document, changed_by: uuid.UUID | None, now: datetime.datetime, comment: str | None = None) -> list[uuid.UUID]` — параметр `user_id` переименован в `changed_by`; при `changed_by is None` привязка создаётся, переход версии пропускается с `logger.info`. + +- [ ] **Step 1: Найти всех вызывающих** + +Run: `cd api && grep -rn "on_document_confirmed" src tests` +Expected: `src/services/documents.py` (confirm) и `tests/test_consolidation_documents.py` (`test_on_document_confirmed_returns_bound_versions` и, возможно, `_confirm_in`). Все они будут переименованы на `changed_by=`. + +- [ ] **Step 2: Написать падающие тесты** + +В конец раздела «on_document_confirmed» файла `api/tests/test_consolidation_documents.py` (после `test_on_document_confirmed_returns_bound_versions`) добавить: + +```python +@pytest.mark.integration +async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None: + """Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _confirmed(seeded, entities[0], quarter=Q2) + history = len(await _history(seeded, version.id)) + + bound = await consolidation_documents.on_document_confirmed( + seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now() + ) + + assert bound == [version.id] + assert document.id in await _bound(seeded, version.id) + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + assert len(await _history(seeded, version.id)) == history + + +@pytest.mark.integration +async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None: + """Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + user = await _user(seeded) + document = await _confirmed(seeded, entities[0], quarter=Q2) + + await consolidation_documents.on_document_confirmed( + seeded, + AuditService(seeded), + document=document, + changed_by=user.id, + comment="автоподтверждение документа OSV 60", + now=utc_now(), + ) + + last = (await _history(seeded, version.id))[-1] + assert (last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DATA_CORRECTION, + user.id, + "автоподтверждение документа OSV 60", + ) +``` + +- [ ] **Step 3: Запустить — должны упасть** + +Run: `cd api && .venv/bin/pytest tests/test_consolidation_documents.py -k "without_changed_by or writes_comment" -v` +Expected: FAIL — `TypeError: on_document_confirmed() got an unexpected keyword argument 'changed_by'`. + +- [ ] **Step 4: Изменить хук** + +В `api/src/services/consolidation_documents.py` заменить сигнатуру и тело `on_document_confirmed`: + +```python +async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157) + session: AsyncSession, + audit: AuditService, + *, + document: Document, + changed_by: uuid.UUID | None, + now: datetime.datetime, + comment: str | None = None, +) -> list[uuid.UUID]: + """Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»): + привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ + того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и + `comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и + «автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход + пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших + привязку (по возрастанию). + + Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК + (создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2). + Блокировка версии (`consolidation_lock`) не требуется. + """ + # цикл импортов: consolidation_status → consolidations → consolidation_documents + from src.services.consolidation_status import transition # noqa: PLC0415 + + versions = await repo.lock_versions_for_entity(session, document.legal_entity_id, IN_WORK_STATUSES) + bound: list[uuid.UUID] = [] + for version in versions: + consolidation = await session.get(Consolidation, version.consolidation_id) + if consolidation is None: # FK гарантирует наличие + continue + if not fits(document, version, consolidation, await _member_ids(session, version.id)): + continue + await _bind(session, version.id, document) + if version.status in MOVES_TO_CORRECTION: + if changed_by is None: + logger.info( + "Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). " + "version_id={version_id}, document_id={document_id}", + version_id=version.id, + document_id=document.id, + ) + else: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DATA_CORRECTION, + changed_by=changed_by, + comment=comment, + now=now, + ) + bound.append(version.id) + return bound +``` + +В `api/src/services/documents.py`, в `DocumentService.confirm`, заменить вызов: + +```python + await consolidation_documents.on_document_confirmed( + self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now + ) +``` + +В `api/tests/test_consolidation_documents.py` заменить у остальных вызовов `user_id=` на `changed_by=` (найдены в Step 1). + +- [ ] **Step 5: Запустить тесты модуля** + +Run: `cd api && .venv/bin/pytest tests/test_consolidation_documents.py tests/test_api_documents.py -v` +Expected: PASS (новые два и все прежние). + +- [ ] **Step 6: Линтеры** + +Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make lint` +Expected: ruff и mypy без ошибок. + +- [ ] **Step 7: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/documents.py api/tests/test_consolidation_documents.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(consolidations): on_document_confirmed принимает автора и комментарий перехода (#64) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Условия подтверждения формы — одна функция для ручного и автоматического пути + +**Files:** +- Modify: `api/src/services/statements.py` (модульные функции рядом с `FORM_TYPES`; метод `StatementService.hard_failures`) +- Modify: `api/src/services/documents.py` (`_check_statement` использует `statement_hard_failures`) +- Test: `api/tests/test_form_confirmable.py` (новый) + +**Interfaces:** +- Produces: `async def statement_hard_failures(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]` — жёсткие нарушения §7; при акцепте `()`. +- Produces: `async def form_confirmable(session: AsyncSession, document: Document) -> bool` — `True` для не-формы (`document_type ∉ FORM_TYPES`) и для формы без шапки; для формы с шапкой — `period_year` и `period_quarter` не `None` и `statement_hard_failures(...) == ()`. + +- [ ] **Step 1: Написать падающий тест** + +Создать `api/tests/test_form_confirmable.py`: + +```python +"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b).""" + +import json +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue +from src.services.statements import form_confirmable, statement_hard_failures +from tests.factories import make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" +SYNTH = GOLDEN_STATEMENTS / "synthetic" + + +async def _owner(session: AsyncSession, path: Path) -> LegalEntity: + """Владелец с ИНН шапки формы (MATCH).""" + 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 _income(session: AsyncSession, fixtures_dir: Path) -> Document: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + (income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + return income + + +async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: + assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True + + +async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + income.period_quarter = None + await seeded.flush() + assert await form_confirmable(seeded, income) is False + + +async def test_form_with_hard_failure_is_not_confirmable_until_accepted( + seeded: AsyncSession, fixtures_dir: Path +) -> None: + income = await _income(seeded, fixtures_dir) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + # ломаем итог 2400 «Чистая прибыль» во всех колонках: соотношение 2400 = … перестаёт сходиться + values = ( + await seeded.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2400") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await seeded.flush() + + assert await statement_hard_failures(seeded, income, meta) != () + assert await form_confirmable(seeded, income) is False + + meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор + await seeded.flush() + assert await form_confirmable(seeded, income) is True + + +async def test_disclosure_is_confirmable(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 + ) + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert await form_confirmable(seeded, disclosure) is True +``` + +- [ ] **Step 2: Запустить — должен упасть** + +Run: `cd api && .venv/bin/pytest tests/test_form_confirmable.py -v` +Expected: FAIL — `ImportError: cannot import name 'form_confirmable'`. + +- [ ] **Step 3: Реализовать** + +В `api/src/services/statements.py` сразу после `FORM_TYPES = frozenset({...})` добавить: + +```python +async def statement_hard_failures( + session: AsyncSession, document: Document, meta: FinancialStatementMeta +) -> tuple[FailedCheck, ...]: + """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" + if meta.checks_accepted_at is not None: + return () + column_indexes, amounts = await repo.statement_amounts(session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules()) + return tuple(report.failed) + + +async def form_confirmable(session: AsyncSession, document: Document) -> bool: + """Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): + период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) + — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409.""" + if document.document_type not in FORM_TYPES: + return True + meta = await repo.get_meta(session, document.id, refresh=True) + if meta is None: + return True + if document.period_year is None or document.period_quarter is None: + return False + return not await statement_hard_failures(session, document, meta) +``` + +Заменить тело метода `StatementService.hard_failures`: + +```python + async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: + """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" + return await statement_hard_failures(self._session, document, meta) +``` + +В `api/src/services/documents.py`, в `_check_statement`, заменить импорт и вызов: + +```python + from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта +``` +```python + failures = await statement_hard_failures(self._session, document, meta) +``` + +(Импорты `FailedCheck`, `run_checks`, `load_check_rules` в `statements.py` уже есть — их использует метод; проверить `grep -n "FailedCheck\|run_checks\|load_check_rules" api/src/services/statements.py`.) + +- [ ] **Step 4: Запустить тесты** + +Run: `cd api && .venv/bin/pytest tests/test_form_confirmable.py tests/test_api_statements.py tests/test_api_documents.py -v` +Expected: PASS. + +- [ ] **Step 5: Линтеры и commit** + +Run: `make lint` (из `.wt/auto-confirm/arm-core`), затем: + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/statements.py api/src/services/documents.py api/tests/test_form_confirmable.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "refactor(statements): form_confirmable — условия подтверждения формы для ручного и автоматического пути (#64) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: `auto_confirm` — подтверждение чистых документов файла + +**Files:** +- Create: `api/src/services/auto_confirm.py` +- Test: `api/tests/test_auto_confirm.py` (новый) + +**Interfaces:** +- Consumes: `on_document_confirmed(..., changed_by=..., comment=...)` (Task 1); `form_confirmable(session, document)` и `FORM_TYPES` из `src.services.statements` (Task 2). +- Produces: `AUTO_CONFIRM_DESCRIPTION: str = "автоподтверждение: без замечаний"`; `def transition_comment(document: Document) -> str`; `async def auto_confirm(session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime) -> list[Document]` — возвращает подтверждённые документы (по возрастанию `id`). + +- [ ] **Step 1: Написать падающие тесты** + +Создать `api/tests/test_auto_confirm.py`: + +```python +"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; OQ-157; design D-1–D-3).""" + +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.clock import utc_now +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import ( + AuditAction, + ConsolidationStatus, + DocumentStatus, + DocumentType, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, StoredFile +from src.models.statements import FinancialStatementMeta +from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment +from tests.conftest import AuthClientFactory +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 + +ROSTOV = "Ростовский региональный филиал" +KA_RF = "ka_rf@armka.local" +YEAR = 2024 +Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3 +HTTP_CONFLICT = 409 + + +async def _user(session: AsyncSession) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + + +async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)] + + +async def _version( + session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus +) -> ConsolidationVersion: + _, version = await make_consolidation( + session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status + ) + return version + + +async def _file_with( + session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None +) -> tuple[StoredFile, list[Document]]: + """Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`.""" + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by + ) + documents = [ + await make_document( + session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False + ) + for account, status in zip(["60", "62", "76"], statuses, strict=False) + ] + return file, documents + + +async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _confirm_events(session: AsyncSession) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) + return list((await session.execute(stmt)).scalars()) + + +def test_transition_comment() -> None: + osv = Document(document_type=DocumentType.OSV, osv_account_code="60") + income = Document(document_type=DocumentType.INCOME, osv_account_code=None) + assert transition_comment(osv) == "автоподтверждение документа OSV 60" + assert transition_comment(income) == "автоподтверждение документа INCOME" + + +async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + now = utc_now() + + confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now) + + assert confirmed == [document] + assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now) + [event] = await _confirm_events(seeded) + assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION) + assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"}) + + +async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], uuid.uuid4()) + + assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == [] + assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None + assert await _confirm_events(seeded) == [] + assert await _bound(seeded, version.id) == set() + + +async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + + assert await _bound(seeded, version.id) == {document.id} + refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION + last = ( + await seeded.execute( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version.id) + .order_by(ConsolidationStatusHistory.changed_at.desc()) + .limit(1) + ) + ).scalar_one() + assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60") + + +async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None) + + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + + assert document.status is DocumentStatus.CONFIRMED + assert await _bound(seeded, version.id) == {document.id} + refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED + + +async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + file, documents = await _file_with( + seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], uuid.uuid4() + ) + + confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now()) + + assert confirmed == [documents[0]] + assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK] + + +@pytest.mark.fixtures +async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.status is DocumentStatus.RECOGNIZED + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + now = utc_now() + + assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income] + + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now) + + +@pytest.mark.fixtures +async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам + await seeded.flush() + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == [] + assert income.status is DocumentStatus.RECOGNIZED + + +async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`.""" + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + entity = await make_legal_entity(seeded, name="ООО Ростов", office=office) + file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None) + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED" + assert len(await _confirm_events(seeded)) == 1 +``` + +Перед запуском свериться с фабриками: `grep -n "async def make_legal_entity" -A12 api/tests/factories.py` — если у `make_legal_entity` нет параметра `office`, в `test_manual_confirm_of_auto_confirmed_is_409` создать ЮЛ через `_group(seeded)` (ГК в Ростовском филиале — ЮЛ видно `ka_rf`) и взять `entities[0]`. + +- [ ] **Step 2: Запустить — должны упасть** + +Run: `cd api && .venv/bin/pytest tests/test_auto_confirm.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.services.auto_confirm'`. + +- [ ] **Step 3: Реализовать модуль** + +Создать `api/src/services/auto_confirm.py`: + +```python +"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого +документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`). + +Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится +`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без +пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором +`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). +""" + +import datetime +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import AuditAction, AuditSection, DocumentStatus +from src.models.files import Document, StoredFile +from src.repositories import statements as statements_repo +from src.services import consolidation_documents +from src.services.audit import AuditService +from src.services.statements import FORM_TYPES, form_confirmable + +AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" +OBJECT_TYPE = "document" + + +def transition_comment(document: Document) -> str: + """Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6).""" + parts = ["автоподтверждение документа", document.document_type.value] + if document.osv_account_code: + parts.append(document.osv_account_code) + return " ".join(parts) + + +async def auto_confirm( + session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime +) -> list[Document]: + """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие + не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" + audit = AuditService(session) + confirmed: list[Document] = [] + for document in sorted(documents, key=lambda item: item.id): + if document.status is not DocumentStatus.RECOGNIZED: + continue + if not await form_confirmable(session, document): + continue + document.status = DocumentStatus.CONFIRMED + document.confirmed_by = None + document.confirmed_at = now + document.updated_at = now + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(session, document.id) + if meta is not None: + meta.reviewed_by = None + meta.reviewed_at = now + await session.flush() + await consolidation_documents.on_document_confirmed( + session, + audit, + document=document, + changed_by=file.uploaded_by, + comment=transition_comment(document), + now=now, + ) + await audit.record( + action=AuditAction.DOCUMENT_CONFIRM, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=document.id, + user_id=None, + description=AUTO_CONFIRM_DESCRIPTION, + before_value={"status": DocumentStatus.RECOGNIZED.value}, + after_value={"status": DocumentStatus.CONFIRMED.value}, + ) + confirmed.append(document) + return confirmed +``` + +- [ ] **Step 4: Запустить тесты** + +Run: `cd api && .venv/bin/pytest tests/test_auto_confirm.py -v` +Expected: PASS (11 тестов). Если падает импорт из-за цикла (`statements` ↔ `documents`) — проверить `python -c "import src.services.auto_confirm"` из `api/` и перенести импорт `form_confirmable` внутрь функции с `# noqa: PLC0415 - цикл импорта`. + +- [ ] **Step 5: Линтеры и commit** + +Run: `make lint`, затем: + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/auto_confirm.py api/tests/test_auto_confirm.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(documents): auto_confirm — чистый документ подтверждается системой (#64, OQ-157) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Автоподтверждение в цикле маппинга + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`_map_candidate`, ~строки 897–902; docstring модуля — шаг маппинга) +- Modify: `api/tests/test_result_mapping.py` (ожидания `RECOGNIZED` после `process_once`: строки ~239, ~620, `test_clean_result`, `test_mapping_moves_documents_processing_version_only_for_needs_check`) +- Modify: `api/tests/e2e/test_osv_ingest_e2e.py` (строки ~144, ~150) +- Test: `api/tests/test_result_mapping.py` (новые тесты в конце раздела «переход версий по маппингу») + +**Interfaces:** +- Consumes: `auto_confirm(session, *, file, documents, now)` (Task 3). + +- [ ] **Step 1: Написать падающие тесты** + +В `api/tests/test_result_mapping.py` после `test_mapping_does_not_move_initiated_version` добавить (импорты `ConsolidationDocument`, `AuditEvent`, `AuditAction` — добавить в шапку файла, если их нет; `func`, `select` уже импортированы): + +```python +async def _history_of(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> list[ConsolidationStatusHistory]: + async with maker() as session: + stmt = ( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version_id) + .order_by(ConsolidationStatusHistory.changed_at) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]: + async with maker() as session: + stmt = select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version_id + ) + return set((await session.execute(stmt)).scalars()) + + +async def test_clean_document_is_auto_confirmed_bound_and_moves_version( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к + версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + file_id, _, _ = await _prepare( + maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None) + assert document.confirmed_at is not None + assert await _bound_ids(maker, version_id) == {document.id} + last = (await _history_of(maker, version_id))[-1] + assert (last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DATA_CORRECTION, + user_id, + "автоподтверждение документа OSV 60", + ) + async with maker() as session: + events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id + ) + ) + ).scalars().all() + assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")] + + +async def test_mixed_file_confirms_clean_and_moves_version_once( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + result = _minimal() + second = copy.deepcopy(result["documents"][0]) + second["header"]["account_code_raw"] = "62" + second["locator"] = "sheet:Лист_2" + second["checks"] = {"total_row_matches_root": False} # нарушение проверок → NEEDS_CHECK + result["documents"].append(second) + file_id, _, _ = await _prepare( + maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + + assert await process_once(maker, storage, _settings()) is True + + statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id)) + assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)] + history = await _history_of(maker, version_id) + assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1 + + +async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + entity = await _load_entity(maker, entity_id) + first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id) + assert await process_once(maker, storage, _settings()) is True + [first] = await _documents(maker, first_id) + assert await _bound_ids(maker, version_id) == {first.id} + + second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id) + assert await process_once(maker, storage, _settings()) is True + + [second] = await _documents(maker, second_id) + assert second.status is DocumentStatus.CONFIRMED + assert await _bound_ids(maker, version_id) == {second.id} + assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED + + +async def test_auto_confirm_rolls_back_with_mapping( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, _, user_id = await _version_in(maker) + file_id, job_id, _ = await _prepare( + maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + original = consolidation_documents.on_documents_mapped + calls = {"n": 0} + + async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]: + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("сбой после автоподтверждения") + return await original(*args, **kwargs) + + monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky) + + assert await process_once(maker, storage, _settings()) is False # повторяемая неудача (R-15) + assert await _documents(maker, file_id) == [] # маппинг вместе с автоподтверждением откатился целиком + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.status is DocumentStatus.CONFIRMED + async with maker() as session: + count = ( + await session.execute( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) + ) + ).scalar_one() + assert count == 1 +``` + +Проверить имена в шапке `test_result_mapping.py`: `grep -n "^from\|^import" api/tests/test_result_mapping.py` — нужны `copy`, `Any`, `uuid`, `consolidation_documents` (`from src.services import consolidation_documents`), `ConsolidationDocument`, `ConsolidationStatusHistory`, `AuditEvent`, `AuditAction`. Ключи снимка `_minimal()` (`header.account_code_raw`, `locator`, `checks`) сверить: `python -c "import json;print(json.load(open('tests/data/'))['documents'][0].keys())"` (путь — константа `MINIMAL_RESULT` в начале файла). Между двумя `process_once` пауза не нужна: `process_once` вызывается напрямую, цикл со сном не участвует; если кандидат после неудачи не берётся повторно (например, отсечка по `finished_at`), посмотреть `pick_candidate` и при необходимости вызвать `_map_candidate` вторично через тот же кандидат. + +- [ ] **Step 2: Запустить — должны упасть** + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py -k "auto_confirmed or mixed_file or supersede_rebinds or rolls_back" -v` +Expected: FAIL — документ `RECOGNIZED`, а не `CONFIRMED`. + +- [ ] **Step 3: Вызвать auto_confirm в маппинге** + +В `api/src/services/result_mapping.py` добавить импорт `from src.services.auto_confirm import auto_confirm` (рядом с `from src.services import consolidation_documents`) и заменить хвост `_map_candidate`: + +```python + warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now) + # После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии + # блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала + # автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по + # `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает. + documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars()) + await auto_confirm(session, file=file, documents=documents, now=now) + await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now) +``` + +- [ ] **Step 4: Обновить прежние ожидания** + +В `api/tests/test_result_mapping.py`: +- строка ~239: `assert document.status is DocumentStatus.CONFIRMED # done, проверки чистые, шапка = владелец по имени → автоподтверждение`; +- строка ~620 (ветка `else`): `assert document.status is DocumentStatus.CONFIRMED`; +- `test_clean_result`: в `parametrize` `(RecognitionStatus.done, DocumentStatus.CONFIRMED)`, docstring «… → `CONFIRMED` (автоподтверждение, OQ-157), `MATCH`, `UNKNOWN`»; +- `test_mapping_moves_documents_processing_version_only_for_needs_check`: ветку чистого документа переписать — документ `CONFIRMED`, `bindings == 1`, версия `DATA_CORRECTION`, последняя запись истории с `changed_by = user_id` и комментарием «автоподтверждение документа OSV 60»; для `NEEDS_CHECK` — как было (`bindings == 0`). Утверждение `assert bindings == 0` перенести внутрь ветки `if moved:`. + +В `api/tests/e2e/test_osv_ingest_e2e.py` строки ~144 и ~150: `"RECOGNIZED"` → `"CONFIRMED"`, комментарий «чистая ОСВ подтверждена автоматически (OQ-157)». Если ниже в e2e есть `POST …/confirm` для этого документа — ожидать 409 `ALREADY_CONFIRMED` (проверить `grep -n "confirm" api/tests/e2e/test_osv_ingest_e2e.py`). + +Найти остальные ожидания `RECOGNIZED` после `process_once`: `grep -n "process_once" -A12 api/tests/test_result_mapping.py api/tests/test_api_files_delete.py | grep RECOGNIZED` — каждое заменить на `CONFIRMED` с тем же комментарием. Тесты, вызывающие `map_file` напрямую (`test_mapping_statements.py`, `test_mapping_golden.py`, `fixtures_helpers.map_golden_snapshot`), НЕ меняются: автоподтверждение — в `_map_candidate`, не в `map_file`. + +- [ ] **Step 5: Запустить** + +Run: `cd api && .venv/bin/pytest tests/test_result_mapping.py tests/test_consolidation_documents.py tests/test_mapping_statements.py tests/test_api_consolidation_checklist.py -v` +Expected: PASS. + +- [ ] **Step 6: Обновить docstring модуля маппинга** + +В docstring `api/src/services/result_mapping.py` (модельные шаги прохода) дописать к шагу 3: «… → автоподтверждение чистых документов (`services/auto_confirm.py`, OQ-157) → переход версий по `NEEDS_CHECK`». + +- [ ] **Step 7: Линтеры и commit** + +Run: `make lint`, затем: + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/result_mapping.py api/tests/test_result_mapping.py api/tests/e2e/test_osv_ingest_e2e.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(mapping): автоподтверждение чистых документов в цикле маппинга (#64, OQ-157) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Удаление файла с автоподтверждёнными документами + +**Files:** +- Modify: `api/src/services/consolidation_documents.py` (новые `bound_versions`, `unbind_documents`) +- Modify: `api/src/services/primary_files.py` (`PrimaryFilesService.delete`, ~строки 281–330) +- Modify: `api/src/api/endpoints/files/read.py` (описание 409 у `DELETE`, ~строка 34) +- Test: `api/tests/test_api_files_delete.py` + +**Interfaces:** +- Produces: `async def bound_versions(session: AsyncSession, *, documents: Sequence[Document]) -> list[tuple[uuid.UUID, ConsolidationStatus]]` — блокирует версии «в работе» ЮЛ документов (`lock_versions_for_entity` по каждому ЮЛ, по возрастанию id ЮЛ) и возвращает пары (версия, статус) всех привязок `documents` (любой статус версии), отсортированные по id версии. +- Produces: `async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int` — удаляет все `consolidation_document` этих документов. + +- [ ] **Step 1: Написать падающие тесты** + +В `api/tests/test_api_files_delete.py`: + +1) В `test_confirmed_document_blocks_delete` документ `CONFIRMED` сделать подтверждённым пользователем — после `make_document(... account="62", status=DocumentStatus.CONFIRMED)` сохранить его в переменную `confirmed` и добавить: + +```python + confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id + await seeded.flush() +``` + +2) В конец файла добавить (импорты `AppUser`, `ConsolidationDocument`, `ConsolidationVersion`, `ConsolidationStatus`, `make_company_group`, `make_consolidation`, `PeriodQuarter`, `utc_now` — добавить в шапку при отсутствии): + +```python +async def _auto_confirmed_in_versions( + session: AsyncSession, statuses: list[ConsolidationStatus] +) -> tuple[StoredFile, Document, list[ConsolidationVersion]]: + """ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60 + за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям.""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал"))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group) + other = await make_legal_entity(session, name="ООО Второе", group=group) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + versions = [] + for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False): + _, version = await make_consolidation( + session, group=group, year=2024, quarter=quarter, members=[entity, other], initiator=initiator, status=status + ) + versions.append(version) + file, job = await make_stored_file(session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done) + document = await make_document( + session, file=file, job=job, account="60", year=2024, quarter=PeriodQuarter.Q2, + status=DocumentStatus.CONFIRMED, is_cumulative=False, + ) + document.confirmed_at = utc_now() + for version in versions: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + return file, document, versions + + +async def test_delete_auto_confirmed_in_correction_unbinds(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION]) + file_id, version_id = file.id, version.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0 + refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION + [event] = await _delete_events(seeded) + assert event.after_value == {"unbound_version_ids": [str(version_id)]} + + +async def test_delete_blocked_if_any_bound_version_left_in_work( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята.""" + file, document, versions = await _auto_confirmed_in_versions( + seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF] + ) + file_id, document_id = file.id, document.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS" + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len(versions) + assert await _delete_events(seeded) == [] + + +async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done) + await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED) # confirmed_by NULL + file_id = file.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + [event] = await _delete_events(seeded) + assert event.after_value == {"unbound_version_ids": []} +``` + +Проверить: `HTTP_NO_CONTENT`, `HTTP_CONFLICT`, `_count`, `_delete_events`, `_rostov_entity`, `KA_RF`, `RegionalOffice` уже определены/импортированы в файле (`grep -n "HTTP_NO_CONTENT\|HTTP_CONFLICT\|^KA_RF\|RegionalOffice" api/tests/test_api_files_delete.py`); `make_consolidation` переводит версию в нужный статус, включая `CONSOLIDATION_RF` (`grep -n "async def make_consolidation" -A30 api/tests/factories.py`) — если не умеет, после создания выставить `version.status = ConsolidationStatus.CONSOLIDATION_RF` и `flush`. Формат `after_value` (`str` UUID или UUID) сверить с тем, как `FILE_REPLACE.unbound_version_ids` проверяется в `test_result_mapping.py` (`grep -n "unbound_version_ids" api/tests/test_result_mapping.py`) и привести ожидания к нему. Также проверить `test_delete_error_file`: если он сравнивает `after_value` с `None`, заменить ожидание на `{"unbound_version_ids": []}`. + +- [ ] **Step 2: Запустить — должны упасть** + +Run: `cd api && .venv/bin/pytest tests/test_api_files_delete.py -v` +Expected: FAIL — 409 вместо 204 у `test_delete_auto_confirmed_in_correction_unbinds` и `test_delete_auto_confirmed_without_bindings`. + +- [ ] **Step 3: Функции привязок** + +В `api/src/services/consolidation_documents.py` добавить (после `unbind_entity`): + +```python +async def bound_versions( + session: AsyncSession, *, documents: Sequence[Document] +) -> list[tuple[uuid.UUID, ConsolidationStatus]]: + """Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление + файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ + (`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием + привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено.""" + if not documents: + return [] + for entity_id in sorted({document.legal_entity_id for document in documents}): + await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES) + stmt = ( + select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status) + .join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id) + .where(ConsolidationDocument.document_id.in_([document.id for document in documents])) + .order_by(ConsolidationDocument.consolidation_version_id) + .execution_options(populate_existing=True) + ) + return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()] + + +async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int: + """Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой + вызывающего (`bound_versions`).""" + if not document_ids: + return 0 + result = await session.execute( + delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids))) + ) + return _rowcount(result) +``` + +- [ ] **Step 4: Правило удаления** + +В `api/src/services/primary_files.py` добавить импорт `from src.services import consolidation_documents` и `DocumentStatus` (если нет) и заменить в `delete` блок после `documents = await files_repo.lock_file_documents(...)` до `current = next(...)`: + +```python + confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED] + if any(document.confirmed_by is not None for document in confirmed): + raise ConflictError( + FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем" + ) + if await files_repo.supersedes_others(self._session, file_id): + raise ConflictError( + FILE_SUPERSEDES_OTHERS, + "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется", + ) + # OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются + bound = await consolidation_documents.bound_versions(self._session, documents=confirmed) + if any(status not in IN_WORK_STATUSES for _, status in bound): + raise ConflictError( + FILE_HAS_CONFIRMED_DOCUMENTS, + "файл нельзя удалить: документ уже участвует в версии консолидации на согласовании", + ) + unbound_version_ids = sorted({version_id for version_id, _ in bound}) + await consolidation_documents.unbind_documents( + self._session, document_ids=[document.id for document in confirmed] + ) +``` + +В вызове `self._audit.record(action=AuditAction.FILE_DELETE, ...)` добавить `after_value={"unbound_version_ids": unbound_version_ids},`. Обновить docstring `delete`: «409 `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии вне «в работе» (OQ-157); привязки автоподтверждённых документов снимаются, `after_value.unbound_version_ids`». Порядок 409 в docstring: пользовательское подтверждение → `FILE_SUPERSEDES_OTHERS` → версия на согласовании. + +В `api/src/api/endpoints/files/read.py` (описание 409 у `DELETE`): «`FILE_HAS_CONFIRMED_DOCUMENTS` (документ подтверждён пользователем или автоподтверждённый документ в версии на согласовании) или `FILE_SUPERSEDES_OTHERS` …». + +- [ ] **Step 5: Запустить** + +Run: `cd api && .venv/bin/pytest tests/test_api_files_delete.py tests/test_consolidation_documents.py -v` +Expected: PASS. + +- [ ] **Step 6: Линтеры и commit** + +Run: `make lint`, затем: + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add api/src/services/consolidation_documents.py api/src/services/primary_files.py api/src/api/endpoints/files/read.py api/tests/test_api_files_delete.py +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "feat(files): удаление файла с автоподтверждёнными документами до согласования (#64, OQ-157) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Документация arm-core и полная проверка + +**Files:** +- Modify: `CLAUDE.md` (arm-core: раздел «Первичные документы (Срез 2c)» и «Консолидации (Срез 4)») +- Modify: `README.md` (arm-core: каталог ошибок — `FILE_HAS_CONFIRMED_DOCUMENTS`; поведение подтверждения) +- Modify: `openspec/changes/auto-confirm-clean-documents/design.md` (раздел «Decisions made without discussion» — дописать решения, принятые при реализации, если были) + +- [ ] **Step 1: CLAUDE.md** + +В разделе «Первичные документы (Срез 2c)» после фразы о `services/documents.py (карточки, confirm)` добавить: «`services/auto_confirm.py` — автоподтверждение чистых документов в транзакции маппинга (`_map_candidate`: `map_file` → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157)». В перечне аудита: «`DOCUMENT_CONFIRM` (пользователь или система при автоподтверждении)». В разделе «Консолидации» у хуков: «`on_document_confirmed(changed_by, comment)`; `bound_versions` / `unbind_documents` — удаление файла с автоподтверждёнными документами (OQ-157)». + +- [ ] **Step 2: README** + +Найти каталог ошибок: `grep -n "FILE_HAS_CONFIRMED_DOCUMENTS" README.md`. Обновить строку: «409 — в файле есть документ, подтверждённый пользователем, или автоподтверждённый документ привязан к версии на согласовании (вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION)». Если в README описан жизненный цикл документа (`grep -n "RECOGNIZED" README.md`) — дописать: «чистый документ при маппинге сразу `CONFIRMED` (система, `confirmed_by = null`, OQ-157); `RECOGNIZED` остаётся у документов до этой версии и у форм после правки». + +- [ ] **Step 3: Полная проверка** + +Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core && make check-full 2>&1 | tail -40` +Expected: lint, unit, dbml-check, check-infra, integration, e2e (skip без профиля services — допустимо, если так и было), docker-build, web-check — зелёные. Падения от окружения (стенд владельца в той же queue group NATS) — записать в ledger как O-находку с выводом и НЕ гасить стенд владельца без вопроса. + +- [ ] **Step 4: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core add CLAUDE.md README.md openspec/changes/auto-confirm-clean-documents/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/arm-core commit -m "docs: автоподтверждение чистых документов в CLAUDE.md и README (#64) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: Правило, OQ и гайды в arm-ka-docs + +**Files (репозиторий docs, worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs`, ветка `feat/64-auto-confirm-rules`):** +- Modify: `context/05-domain-rules.md` (§9.3, §9.5 п. 4 и п. 5) +- Modify: `context/07-open-questions.md` (новая строка OQ-157 после OQ-156; пометки в строках OQ-05 и OQ-110) +- Modify: `dev/qa-guide.md` (§8 W13, W16–W18, §7.1; раздел 6 про подтверждение и удаление) +- Modify: `dev/frontend-api-guide.md` (статусы документа, `confirmed_by = null`, удаление) + +- [ ] **Step 1: §9.3 и §9.5** + +В `context/05-domain-rules.md`, §9.3 (абзац «`RECOGNIZED` — задание `done` …»), после «(OQ-05 закрыт: `RECOGNIZED` нужен)» вставить: «**Автоподтверждение (OQ-157, решение владельца 08.10.2026; ТЗ 03_9 п. 4, 10_10):** документ, получивший при маппинге `RECOGNIZED`, в той же транзакции становится `CONFIRMED` от имени системы (`confirmed_by` NULL, `audit_event(DOCUMENT_CONFIRM)` с `user_id` NULL и описанием «автоподтверждение: без замечаний»), привязывается к версиям и двигает их, как при ручном подтверждении (§3 п. 1); переход версии — от `stored_file.uploaded_by` с комментарием «автоподтверждение документа …». Форма ББ/ОФР — только при условиях ручного подтверждения (п. 4); `RECOGNIZED` формы после правки или акцепта — только вручную. `NEEDS_CHECK` — только вручную.» + +В §9.5 п. 4 (Подтверждение) дописать: «Автоподтверждённый документ (`confirmed_by` NULL) повторно подтвердить нельзя — 409 `ALREADY_CONFIRMED`.» В §9.5 п. 5 (Удаление) заменить условие запрета: «запрещено, если у файла есть документ, подтверждённый пользователем (`confirmed_by` не NULL), или автоподтверждённый документ привязан к версии вне `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`FILE_HAS_CONFIRMED_DOCUMENTS`; OQ-157, уточняет OQ-110); привязки автоподтверждённых документов снимаются, статус версий не меняется, `FILE_DELETE.after_value.unbound_version_ids`». + +- [ ] **Step 2: OQ** + +В `context/07-open-questions.md` после строки `| OQ-156 |` вставить: + +``` +| OQ-157 | Чистый документ (без замечаний) требовал ручного «Подтвердить» (`RECOGNIZED` → `CONFIRMED`), хотя строки ОСВ не правятся (OQ-114) и ТЗ 03_9 п. 4 / 10_10 даёт «Загружен» сразу при отсутствии замечаний; на комплекте 1 кв. 2025 — 16 пустых кликов | прогон владельца 08.10.2026 | arm-core `services/auto_confirm.py`, `result_mapping`, `primary_files.delete`; §9.3, §9.5 п. 4–5 | **решено** (владелец 08.10.2026): чистый документ при маппинге сразу `CONFIRMED` системой (`confirmed_by` NULL), переход версии — от загрузившего; удаление автоподтверждённого файла — пока версии «в работе»; RSHB/arm-core#64, change `auto-confirm-clean-documents` | +``` + +В строке OQ-05 в последний столбец дописать: «Пересмотрено OQ-157 (08.10.2026): после маппинга чистый документ сразу `CONFIRMED`; `RECOGNIZED` остаётся у старых документов и форм после правки.» В строке OQ-110 — «Уточнено OQ-157: файл с автоподтверждёнными документами удаляется, пока их версии «в работе»; привязки снимаются.» + +- [ ] **Step 3: Гайды** + +`dev/qa-guide.md`: +- §8 W13: ожидание «у каждого документ … статус «Требуется проверка»» → «чистые ОСВ сразу «Загружен» (автоподтверждение, OQ-157); «Требуется проверка» — только с замечаниями (например, шапка другой организации)»; +- W16–W17: «Просмотр ОСВ» открывается для документа «Загружен», кнопка «✓ Подтвердить» неактивна; проверка подтверждения — на документе «Требуется проверка» (загрузить ОСВ Счастливочной под ЮЛ РБ: `Контрагент ✗`); +- W18: `demo-prepare.sh` подтверждает только неподтверждённое — вывод скрипта без изменений; статус версии «Коррекция данных» появляется сразу после распознавания; +- §6 (раздел про подтверждение и удаление файлов): сценарии «удаление автоподтверждённого файла в «Коррекции данных» → 204, привязка снята» и «версия на согласовании → 409». + +`dev/frontend-api-guide.md`: в разделе статусов документа — «после маппинга чистый документ приходит сразу `CONFIRMED` с `confirmed_by: null` (автоподтверждение); кнопку «Подтвердить» показывать неактивной; удаление файла с таким документом возможно (409 `FILE_HAS_CONFIRMED_DOCUMENTS` — если подтверждал пользователь или версия на согласовании)». + +Точные места найти: `grep -n "W13\|W16\|W17\|W18\|FILE_HAS_CONFIRMED\|RECOGNIZED" dev/qa-guide.md dev/frontend-api-guide.md`. + +- [ ] **Step 4: Commit** + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs add context/05-domain-rules.md context/07-open-questions.md dev/qa-guide.md dev/frontend-api-guide.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/auto-confirm/docs commit -m "docs: автоподтверждение чистых документов — §9.3, §9.5, OQ-157, гайды (RSHB/arm-core#64) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## После задач (контроллер) + +- Финальное ревью всей ветки arm-core (модель opus) по спеке change и Review Focus. +- PR arm-core `feat/64-auto-confirm` → `main` (closes #64; вывод `make check-full`; таблица находок с маркерами; «Rulings I made»); PR arm-ka-docs `feat/64-auto-confirm-rules` → `main` (ссылка на arm-core PR, «сливать вместе»). +- Ничего не мержить; `openspec archive` — только по команде «закрывай». -- 2.47.2 From ff65f2108b856042a37e7c94c6c737e2f2bebc8c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 16:53:28 +0400 Subject: [PATCH 03/10] =?UTF-8?q?refactor(consolidations):=20on=5Fdocument?= =?UTF-8?q?=5Fconfirmed=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/services/consolidation_documents.py | 42 +++++++++++------- api/src/services/documents.py | 2 +- api/tests/test_consolidation_documents.py | 47 ++++++++++++++++++++- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 51098b0..b178b40 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -197,18 +197,22 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_ return _rowcount(result) -async def on_document_confirmed( +async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157) session: AsyncSession, audit: AuditService, *, document: Document, - user_id: uuid.UUID, + changed_by: uuid.UUID | None, now: datetime.datetime, + comment: str | None = None, ) -> list[uuid.UUID]: - """Хук подтверждения (spec «Подтверждение документа»): привязывает уже `CONFIRMED` документ ко всем версиям «в - работе», которым он подходит, заменяя привязанный документ того же ключа (R-1); версия из `INITIATED` / - `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` (`changed_by = user_id`, D-6). Возвращает id версий, - получивших привязку (по возрастанию). + """Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»): + привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ + того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и + `comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и + «автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход + пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших + привязку (по возрастанию). Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК (создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2). @@ -227,15 +231,23 @@ async def on_document_confirmed( continue await _bind(session, version.id, document) if version.status in MOVES_TO_CORRECTION: - await transition( - session, - audit, - version=version, - to=ConsolidationStatus.DATA_CORRECTION, - changed_by=user_id, - comment=None, - now=now, - ) + if changed_by is None: + logger.info( + "Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). " + "version_id={version_id}, document_id={document_id}", + version_id=version.id, + document_id=document.id, + ) + else: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DATA_CORRECTION, + changed_by=changed_by, + comment=comment, + now=now, + ) bound.append(version.id) return bound diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 38c4c37..aae4ec0 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -273,7 +273,7 @@ class DocumentService: 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 + self._session, self._audit.service, document=document, changed_by=self._audit.user_id, now=now ) await self._audit.record( action=AuditAction.DOCUMENT_CONFIRM, diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index d3c091c..38b2765 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -473,7 +473,7 @@ async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession user = await _user(seeded) bound = await consolidation_documents.on_document_confirmed( - seeded, AuditService(seeded), document=document, user_id=user.id, now=utc_now() + seeded, AuditService(seeded), document=document, changed_by=user.id, now=utc_now() ) assert bound == sorted([q2.id, q3.id]) @@ -481,6 +481,49 @@ async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession assert await _bound(seeded, q4.id) == set() +@pytest.mark.integration +async def test_on_document_confirmed_without_changed_by_binds_but_keeps_status(seeded: AsyncSession) -> None: + """Автоподтверждение файла без `uploaded_by` (OQ-157, D-2): привязка есть, перехода версии нет.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _confirmed(seeded, entities[0], quarter=Q2) + history = len(await _history(seeded, version.id)) + + bound = await consolidation_documents.on_document_confirmed( + seeded, AuditService(seeded), document=document, changed_by=None, now=utc_now() + ) + + assert bound == [version.id] + assert document.id in await _bound(seeded, version.id) + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + assert len(await _history(seeded, version.id)) == history + + +@pytest.mark.integration +async def test_on_document_confirmed_writes_comment_to_history(seeded: AsyncSession) -> None: + """Комментарий перехода от вызывающего (автоподтверждение) попадает в историю статусов.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + user = await _user(seeded) + document = await _confirmed(seeded, entities[0], quarter=Q2) + + await consolidation_documents.on_document_confirmed( + seeded, + AuditService(seeded), + document=document, + changed_by=user.id, + comment="автоподтверждение документа OSV 60", + now=utc_now(), + ) + + last = (await _history(seeded, version.id))[-1] + assert (last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DATA_CORRECTION, + user.id, + "автоподтверждение документа OSV 60", + ) + + # --- подхват ---------------------------------------------------------------------------------------------------------- @@ -700,7 +743,7 @@ async def _confirm_in(session: AsyncSession, data: dict[str, Any]) -> list[uuid. document.confirmed_at = now await session.flush() return await consolidation_documents.on_document_confirmed( - session, AuditService(session), document=document, user_id=data["user_id"], now=now + session, AuditService(session), document=document, changed_by=data["user_id"], now=now ) -- 2.47.2 From 588c0988384f1bbf2325ff9f6aa19e6fc96853ad Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 16:57:35 +0400 Subject: [PATCH 04/10] =?UTF-8?q?refactor(statements):=20form=5Fconfirmabl?= =?UTF-8?q?e=20=E2=80=94=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8=D1=8F=20?= =?UTF-8?q?=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=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=83=D1=82=D0=B8?= =?UTF-8?q?=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Условия подтверждения формы ББ/ОФР вынесены в statement_hard_failures и form_confirmable (модульные функции services/statements.py); ручной путь (documents._check_statement) использует тот же statement_hard_failures и отвечает 409. Тест с нарушением строгого соотношения на 2200 (соотношения с 2400 в словаре мягкие). Co-Authored-By: Claude Opus 5.5 --- api/src/services/documents.py | 4 +- api/src/services/statements.py | 31 +++++++++-- api/tests/test_form_confirmable.py | 83 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 api/tests/test_form_confirmable.py diff --git a/api/src/services/documents.py b/api/src/services/documents.py index aae4ec0..393fd60 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -209,7 +209,7 @@ class DocumentService: Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). Для не-формы и документа без шапки — `None`, условий нет. """ - from src.services.statements import FORM_TYPES, StatementService # noqa: PLC0415 - цикл импорта + from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта if document.document_type not in FORM_TYPES: return None @@ -218,7 +218,7 @@ class DocumentService: 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) + failures = await statement_hard_failures(self._session, document, meta) if failures: raise ConflictError( CHECKS_NOT_PASSED, diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 15d964f..9288c12 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -47,6 +47,31 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) +async def statement_hard_failures( + session: AsyncSession, document: Document, meta: FinancialStatementMeta +) -> tuple[FailedCheck, ...]: + """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" + if meta.checks_accepted_at is not None: + return () + column_indexes, amounts = await repo.statement_amounts(session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules()) + return tuple(report.failed) + + +async def form_confirmable(session: AsyncSession, document: Document) -> bool: + """Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): + период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) + — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409.""" + if document.document_type not in FORM_TYPES: + return True + meta = await repo.get_meta(session, document.id, refresh=True) + if meta is None: + return True + if document.period_year is None or document.period_quarter is None: + return False + return not await statement_hard_failures(session, document, meta) + + class StatementService: def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: self._session = session @@ -107,11 +132,7 @@ class StatementService: 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) + return await statement_hard_failures(self._session, document, meta) async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None: """D-6 после правки или акцепта; `CONFIRMED` не трогает. diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py new file mode 100644 index 0000000..67cd9f5 --- /dev/null +++ b/api/tests/test_form_confirmable.py @@ -0,0 +1,83 @@ +"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b).""" + +import json +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue +from src.services.statements import form_confirmable, statement_hard_failures +from tests.factories import make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" +SYNTH = GOLDEN_STATEMENTS / "synthetic" + + +async def _owner(session: AsyncSession, path: Path) -> LegalEntity: + """Владелец с ИНН шапки формы (MATCH).""" + 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 _income(session: AsyncSession, fixtures_dir: Path) -> Document: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + (income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + return income + + +async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: + assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True + + +async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + income.period_quarter = None + await seeded.flush() + assert await form_confirmable(seeded, income) is False + + +async def test_form_with_hard_failure_is_not_confirmable_until_accepted( + seeded: AsyncSession, fixtures_dir: Path +) -> None: + income = await _income(seeded, fixtures_dir) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + # ломаем итог 2200 во всех колонках: строгое соотношение «2200 = 2100 - 2210 - 2220» перестаёт сходиться + # (соотношения с 2400 в словаре мягкие — strict: false, поэтому на них жёсткое нарушение не проверить) + values = ( + await seeded.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await seeded.flush() + + assert await statement_hard_failures(seeded, income, meta) != () + assert await form_confirmable(seeded, income) is False + + meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор + await seeded.flush() + assert await form_confirmable(seeded, income) is True + + +async def test_disclosure_is_confirmable(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 + ) + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert await form_confirmable(seeded, disclosure) is True -- 2.47.2 From b48d9e6f4dcb99c5126dda7cb853413517790228 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:01:29 +0400 Subject: [PATCH 05/10] =?UTF-8?q?feat(documents):=20auto=5Fconfirm=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BE=D0=B9=20(#64,=20OQ-1?= =?UTF-8?q?57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/services/auto_confirm.py | 75 +++++++++++ api/tests/test_auto_confirm.py | 218 +++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 api/src/services/auto_confirm.py create mode 100644 api/tests/test_auto_confirm.py diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py new file mode 100644 index 0000000..f5bb462 --- /dev/null +++ b/api/src/services/auto_confirm.py @@ -0,0 +1,75 @@ +"""Автоподтверждение чистых документов при маппинге результата (osv-result-mapping «Автоподтверждение чистого +документа»; `05-domain-rules.md` §9.3, §9.5; OQ-157; design D-1–D-3 change `auto-confirm-clean-documents`). + +Документ, получивший при маппинге `RECOGNIZED` (ТЗ 03_9 п. 4: «при отсутствии замечаний — „Загружен“»), становится +`CONFIRMED` от имени системы: `confirmed_by = NULL`, у формы `reviewed_by = NULL`, `audit_event(DOCUMENT_CONFIRM)` без +пользователя. Привязка к версиям и переход версии — тот же хук, что у ручного подтверждения, с автором +`stored_file.uploaded_by`. Вызывается в транзакции маппинга под заданием, файлом и документами (порядок блокировок D-5). +""" + +import datetime +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import AuditAction, AuditSection, DocumentStatus +from src.models.files import Document, StoredFile +from src.repositories import statements as statements_repo +from src.services import consolidation_documents +from src.services.audit import AuditService +from src.services.statements import FORM_TYPES, form_confirmable + +AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" +OBJECT_TYPE = "document" + + +def transition_comment(document: Document) -> str: + """Комментарий перехода версии: «автоподтверждение документа <тип> [<счёт>]» (design решение №6).""" + parts = ["автоподтверждение документа", document.document_type.value] + if document.osv_account_code: + parts.append(document.osv_account_code) + return " ".join(parts) + + +async def auto_confirm( + session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime +) -> list[Document]: + """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие + не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" + audit = AuditService(session) + confirmed: list[Document] = [] + for document in sorted(documents, key=lambda item: item.id): + if document.status is not DocumentStatus.RECOGNIZED: + continue + if not await form_confirmable(session, document): + continue + document.status = DocumentStatus.CONFIRMED + document.confirmed_by = None + document.confirmed_at = now + document.updated_at = now + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(session, document.id) + if meta is not None: + meta.reviewed_by = None + meta.reviewed_at = now + await session.flush() + await consolidation_documents.on_document_confirmed( + session, + audit, + document=document, + changed_by=file.uploaded_by, + comment=transition_comment(document), + now=now, + ) + await audit.record( + action=AuditAction.DOCUMENT_CONFIRM, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=document.id, + user_id=None, + description=AUTO_CONFIRM_DESCRIPTION, + before_value={"status": DocumentStatus.RECOGNIZED.value}, + after_value={"status": DocumentStatus.CONFIRMED.value}, + ) + confirmed.append(document) + return confirmed diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py new file mode 100644 index 0000000..2fb4e37 --- /dev/null +++ b/api/tests/test_auto_confirm.py @@ -0,0 +1,218 @@ +"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»; +OQ-157; design D-1–D-3).""" + +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.clock import utc_now +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import ( + AuditAction, + ConsolidationStatus, + DocumentStatus, + DocumentType, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, StoredFile +from src.models.statements import FinancialStatementMeta +from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment +from tests.conftest import AuthClientFactory +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 + +ROSTOV = "Ростовский региональный филиал" +KA_RF = "ka_rf@armka.local" +YEAR = 2024 +Q2, Q3 = PeriodQuarter.Q2, PeriodQuarter.Q3 +HTTP_CONFLICT = 409 + + +async def _user(session: AsyncSession) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + + +async def _group(session: AsyncSession) -> tuple[CompanyGroup, list[LegalEntity]]: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + return group, [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)] + + +async def _version( + session: AsyncSession, group: CompanyGroup, members: list[LegalEntity], status: ConsolidationStatus +) -> ConsolidationVersion: + _, version = await make_consolidation( + session, group=group, year=YEAR, quarter=Q3, members=members, initiator=await _user(session), status=status + ) + return version + + +async def _file_with( + session: AsyncSession, entity: LegalEntity, statuses: list[DocumentStatus], uploaded_by: uuid.UUID | None +) -> tuple[StoredFile, list[Document]]: + """Файл `PROCESSED` с квартальными ОСВ 60, 62, … за 2024 Q2 в статусах `statuses`.""" + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by + ) + documents = [ + await make_document( + session, file=file, job=job, account=account, year=YEAR, quarter=Q2, status=status, is_cumulative=False + ) + for account, status in zip(["60", "62", "76"], statuses, strict=False) + ] + return file, documents + + +async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _confirm_events(session: AsyncSession) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) + return list((await session.execute(stmt)).scalars()) + + +def test_transition_comment() -> None: + osv = Document(document_type=DocumentType.OSV, osv_account_code="60") + income = Document(document_type=DocumentType.INCOME, osv_account_code=None) + assert transition_comment(osv) == "автоподтверждение документа OSV 60" + assert transition_comment(income) == "автоподтверждение документа INCOME" + + +async def test_clean_document_is_confirmed_by_system(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + now = utc_now() + + confirmed = await auto_confirm(seeded, file=file, documents=[document], now=now) + + assert confirmed == [document] + assert (document.status, document.confirmed_by, document.confirmed_at) == (DocumentStatus.CONFIRMED, None, now) + [event] = await _confirm_events(seeded) + assert (event.object_id, event.user_id, event.description) == (document.id, None, AUTO_CONFIRM_DESCRIPTION) + assert (event.before_value, event.after_value) == ({"status": "RECOGNIZED"}, {"status": "CONFIRMED"}) + + +async def test_needs_check_is_left_for_analyst(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id) + + assert await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) == [] + assert document.status is DocumentStatus.NEEDS_CHECK and document.confirmed_at is None + assert await _confirm_events(seeded) == [] + assert await _bound(seeded, version.id) == set() + + +async def test_binds_and_moves_version_with_uploader_and_comment(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + user = await _user(seeded) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], user.id) + + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + + assert await _bound(seeded, version.id) == {document.id} + refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION + last = ( + await seeded.execute( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version.id) + .order_by(ConsolidationStatusHistory.changed_at.desc()) + .limit(1) + ) + ).scalar_one() + assert (last.changed_by, last.comment) == (user.id, "автоподтверждение документа OSV 60") + + +async def test_file_without_uploader_binds_without_transition(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) + file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None) + + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + + assert document.status is DocumentStatus.CONFIRMED + assert await _bound(seeded, version.id) == {document.id} + refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED + + +async def test_mixed_documents_confirm_only_clean(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + file, documents = await _file_with( + seeded, entities[0], [DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK], (await _user(seeded)).id + ) + + confirmed = await auto_confirm(seeded, file=file, documents=documents, now=utc_now()) + + assert confirmed == [documents[0]] + assert [d.status for d in documents] == [DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK] + + +@pytest.mark.fixtures +async def test_clean_form_sets_reviewed_at(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.status is DocumentStatus.RECOGNIZED + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + now = utc_now() + + assert await auto_confirm(seeded, file=file, documents=[income], now=now) == [income] + + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + assert (income.status, meta.reviewed_by, meta.reviewed_at) == (DocumentStatus.CONFIRMED, None, now) + + +@pytest.mark.fixtures +async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + income.period_quarter = None # рассинхрон правил: RECOGNIZED без периода не должен подтверждаться сам + await seeded.flush() + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == [] + assert income.status is DocumentStatus.RECOGNIZED + + +async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`.""" + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + entity = await make_legal_entity(seeded, name="ООО Ростов", office=office) + file, [document] = await _file_with(seeded, entity, [DocumentStatus.RECOGNIZED], None) + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "ALREADY_CONFIRMED" + assert len(await _confirm_events(seeded)) == 1 -- 2.47.2 From 3830e718f49eb9ce75e50c5d2d60ec8884c15ffe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:15:12 +0400 Subject: [PATCH 06/10] =?UTF-8?q?feat(mapping):=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=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=B5=20=D1=87=D0=B8=D1=81=D1=82=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B5=20=D0=BC=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20(#64,=20OQ-157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/services/result_mapping.py | 14 ++- api/tests/e2e/test_osv_ingest_e2e.py | 4 +- api/tests/test_result_mapping.py | 177 ++++++++++++++++++++++++--- 3 files changed, 170 insertions(+), 25 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 1edd5d2..b890f93 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -12,7 +12,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ), - предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`). + предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`) → + автоподтверждение чистых документов (`services/auto_confirm.py`, OQ-157) → переход версий по `NEEDS_CHECK` (D-6). Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции @@ -895,10 +896,15 @@ async def _map_candidate( index = await CounterpartyIndex.load(session) now = clock() warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now) - # Переход версий (D-6) — ПОСЛЕ `map_file`, а значит после автозамены (`supersede_previous` берёт advisory ЮЛ → - # старые файлы → advisory ГК → версии): версии блокируются последними, порядок «задание → файл → документы → - # версии» (D-9) не нарушается. До автозамены хук держал бы версии и затем ждал блокировки старых файлов. + # После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии + # блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала + # автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по + # `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает. documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars()) + # отложенный импорт: цикл documents → result_mapping → auto_confirm → statements → documents + from src.services.auto_confirm import auto_confirm # noqa: PLC0415 + + await auto_confirm(session, file=file, documents=documents, now=now) await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now) logger.info( "Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}", diff --git a/api/tests/e2e/test_osv_ingest_e2e.py b/api/tests/e2e/test_osv_ingest_e2e.py index 170df42..e28a4ec 100644 --- a/api/tests/e2e/test_osv_ingest_e2e.py +++ b/api/tests/e2e/test_osv_ingest_e2e.py @@ -141,13 +141,13 @@ async def test_real_osv_is_parsed_end_to_end( assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True) # MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой assert brief["counterparty_check"] == "MATCH" - assert brief["status"] == "RECOGNIZED" + assert brief["status"] == "CONFIRMED" # чистая ОСВ подтверждена автоматически (OQ-157) document = (await client.get(f"/documents/{brief['id']}")).json() assert document["file_id"] == str(file_id) assert document["legal_entity_id"] == str(entity.id) assert (document["period_year"], document["period_quarter"]) == (2024, "Q4") - assert (document["counterparty_check"], document["status"]) == ("MATCH", "RECOGNIZED") + assert (document["counterparty_check"], document["status"]) == ("MATCH", "CONFIRMED") entries = (await client.get(f"/documents/{brief['id']}/entries")).json() assert entries["total"] == ENTRY_ROWS diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index ae199c5..84d28ed 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -49,6 +49,7 @@ 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 consolidation_documents from src.services import result_mapping as rm from src.services.recognition_exchange import expire_deadlines, handle_message from src.services.result_mapping import ( @@ -236,7 +237,7 @@ async def test_golden_osv_66_mapped( # noqa: PLR0915 - один сценари assert document.is_cumulative is True assert document.locator == "sheet:Лист_1" assert document.legal_entity_id == entity_id - assert document.status is DocumentStatus.RECOGNIZED # done, проверки чистые, шапка = владелец по имени + assert document.status is DocumentStatus.CONFIRMED # done, чистые проверки, шапка = владелец → автоподтверждение assert document.counterparty_check is DocumentCheckResult.MATCH assert document.period_check is DocumentCheckResult.UNKNOWN @@ -617,19 +618,20 @@ async def test_cumulative_by_q1_data( assert document.status is DocumentStatus.NEEDS_CHECK else: assert warnings == [] - assert document.status is DocumentStatus.RECOGNIZED + assert document.status is DocumentStatus.CONFIRMED # чистый → автоподтверждение (OQ-157) @pytest.mark.parametrize( ("job_status", "expected"), - [(RecognitionStatus.done, DocumentStatus.RECOGNIZED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)], + [(RecognitionStatus.done, DocumentStatus.CONFIRMED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)], ) async def test_clean_result( committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, expected: DocumentStatus, ): - """«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`; + """«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `CONFIRMED` (автоподтверждение, + OQ-157), `MATCH`, `UNKNOWN`; задание `needs_check` → `NEEDS_CHECK`.""" maker, storage = committed_sessionmaker, FakeStorage() file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status) @@ -1409,13 +1411,14 @@ async def _load_entity(maker: async_sessionmaker[AsyncSession], entity_id: uuid. @pytest.mark.parametrize( ("job_status", "moved"), - [(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / RECOGNIZED (чистый) + [(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / CONFIRMED (чистый) ) async def test_mapping_moves_documents_processing_version_only_for_needs_check( committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool ): """«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии - 2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; привязок нет.""" + 2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший, привязок нет; чистый + документ → `CONFIRMED`, привязка и тот же переход (OQ-157).""" maker, storage = committed_sessionmaker, FakeStorage() entity_id, version_id, user_id = await _version_in(maker) file_id, _, _ = await _prepare( @@ -1430,22 +1433,14 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check( assert await process_once(maker, storage, _settings()) is True [document] = await _documents(maker, file_id) - assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.RECOGNIZED) + assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.CONFIRMED) async with maker() as session: version = await session.get(ConsolidationVersion, version_id) assert version is not None - history = list( - ( - await session.execute( - select(ConsolidationStatusHistory) - .where(ConsolidationStatusHistory.version_id == version_id) - .order_by(ConsolidationStatusHistory.changed_at) - ) - ).scalars() - ) + history = await _history_of(maker, version_id) bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one() - assert bindings == 0 if moved: + assert bindings == 0 assert version.status is ConsolidationStatus.DATA_CORRECTION last = history[-1] assert (last.from_status, last.to_status, last.changed_by) == ( @@ -1454,8 +1449,16 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check( user_id, ) else: - assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING - assert len(history) == FACTORY_HISTORY # создание и переход фабрики в DOCUMENTS_PROCESSING; новых записей нет + # чистый документ подтверждён системой (OQ-157): привязан, версия перешла от загрузившего с комментарием + assert bindings == 1 + assert version.status is ConsolidationStatus.DATA_CORRECTION + last = history[-1] + assert (last.from_status, last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, + user_id, + "автоподтверждение документа OSV 60", + ) async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]): @@ -1479,6 +1482,142 @@ async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: a assert version.status is ConsolidationStatus.INITIATED +async def _history_of( + maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID +) -> list[ConsolidationStatusHistory]: + async with maker() as session: + stmt = ( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version_id) + .order_by(ConsolidationStatusHistory.changed_at) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]: + async with maker() as session: + stmt = select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version_id + ) + return set((await session.execute(stmt)).scalars()) + + +async def test_clean_document_is_auto_confirmed_bound_and_moves_version( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к + версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + file_id, _, _ = await _prepare( + maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None) + assert document.confirmed_at is not None + assert await _bound_ids(maker, version_id) == {document.id} + last = (await _history_of(maker, version_id))[-1] + assert (last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DATA_CORRECTION, + user_id, + "автоподтверждение документа OSV 60", + ) + async with maker() as session: + events = ( + ( + await session.execute( + select(AuditEvent).where( + AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id + ) + ) + ) + .scalars() + .all() + ) + assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")] + + +async def test_mixed_file_confirms_clean_and_moves_version_once( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + result = _minimal() + second = copy.deepcopy(result["documents"][0]) + second["header"]["account_code_raw"] = "62" + second["locator"] = "sheet:Лист_2" + second["checks"] = {"total_row_matches_root": False} # нарушение проверок → NEEDS_CHECK + result["documents"].append(second) + file_id, _, _ = await _prepare( + maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + + assert await process_once(maker, storage, _settings()) is True + + statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id)) + assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)] + history = await _history_of(maker, version_id) + assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1 + + +async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + entity = await _load_entity(maker, entity_id) + first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id) + assert await process_once(maker, storage, _settings()) is True + [first] = await _documents(maker, first_id) + assert await _bound_ids(maker, version_id) == {first.id} + + second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id) + assert await process_once(maker, storage, _settings()) is True + + [second] = await _documents(maker, second_id) + assert second.status is DocumentStatus.CONFIRMED + assert await _bound_ids(maker, version_id) == {second.id} + assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED + + +async def test_auto_confirm_rolls_back_with_mapping( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, _, user_id = await _version_in(maker) + file_id, job_id, _ = await _prepare( + maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id + ) + original = consolidation_documents.on_documents_mapped + calls = {"n": 0} + + async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]: + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("сбой после автоподтверждения") + return await original(*args, **kwargs) + + monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky) + + assert await process_once(maker, storage, _settings()) is False # повторяемая неудача (R-15) + assert await _documents(maker, file_id) == [] # маппинг вместе с автоподтверждением откатился целиком + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.status is DocumentStatus.CONFIRMED + async with maker() as session: + count = ( + await session.execute( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) + ) + ).scalar_one() + assert count == 1 + + async def test_mapping_without_uploader_skips_transition_without_error( committed_sessionmaker: async_sessionmaker[AsyncSession], ): -- 2.47.2 From 256ddcb34ab312c195eb5807a8b300007a027af4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:27:07 +0400 Subject: [PATCH 07/10] =?UTF-8?q?feat(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=81=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20(#64,=20OQ-157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/endpoints/files/read.py | 3 +- api/src/services/consolidation_documents.py | 34 ++++++ api/src/services/primary_files.py | 26 +++- api/tests/test_api_files_delete.py | 126 +++++++++++++++++++- 4 files changed, 179 insertions(+), 10 deletions(-) diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 1aa9c37..6205f84 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -31,7 +31,8 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = { **NOT_FOUND, status.HTTP_409_CONFLICT: { "model": ErrorDetail, - "description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS " + "description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый " + "документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS " "(файл заменил более ранние версии)", }, } diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index b178b40..c2bf285 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -39,10 +39,12 @@ from src.services.audit import AuditService __all__ = [ "bind_confirmed", + "bound_versions", "fits", "on_document_confirmed", "on_documents_mapped", "on_documents_superseded", + "unbind_documents", "unbind_entity", ] @@ -197,6 +199,38 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_ return _rowcount(result) +async def bound_versions( + session: AsyncSession, *, documents: Sequence[Document] +) -> list[tuple[uuid.UUID, ConsolidationStatus]]: + """Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление + файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ + (`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием + привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено.""" + if not documents: + return [] + for entity_id in sorted({document.legal_entity_id for document in documents}): + await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES) + stmt = ( + select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status) + .join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id) + .where(ConsolidationDocument.document_id.in_([document.id for document in documents])) + .order_by(ConsolidationDocument.consolidation_version_id) + .execution_options(populate_existing=True) + ) + return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()] + + +async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int: + """Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой + вызывающего (`bound_versions`).""" + if not document_ids: + return 0 + result = await session.execute( + delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids))) + ) + return _rowcount(result) + + async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157) session: AsyncSession, audit: AuditService, diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index bd3e34a..df13a32 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -54,6 +54,7 @@ from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.schemas.common import PageParams from src.schemas.files import FileOut +from src.services import consolidation_documents from src.services.audit import AuditService from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.documents import file_out @@ -283,9 +284,11 @@ class PrimaryFilesService: Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы (confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается - под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`, - `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл, - исчезнувший, пока ждали блокировку, — 404. + под блокировками, и 409 проверяются после них, в порядке: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ, + подтверждённый пользователем; `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка + версий не рвётся); `FILE_HAS_CONFIRMED_DOCUMENTS` — автоподтверждённый документ привязан к версии вне «в + работе» (OQ-157). Привязки автоподтверждённых документов снимаются (после блокировки advisory ГК → версии), + id версий — в `after_value.unbound_version_ids`. Файл, исчезнувший, пока ждали блокировку, — 404. Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий @@ -300,15 +303,27 @@ class PrimaryFilesService: if file is None: # удалён параллельно, пока ждали блокировку raise NotFoundError(FILE_NOT_FOUND) documents = await files_repo.lock_file_documents(self._session, file_id) - if any(document.status is DocumentStatus.CONFIRMED for document in documents): + confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED] + if any(document.confirmed_by is not None for document in confirmed): raise ConflictError( - FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы" + FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем" ) if await files_repo.supersedes_others(self._session, file_id): raise ConflictError( FILE_SUPERSEDES_OTHERS, "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется", ) + # OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются + bound = await consolidation_documents.bound_versions(self._session, documents=confirmed) + if any(status not in IN_WORK_STATUSES for _, status in bound): + raise ConflictError( + FILE_HAS_CONFIRMED_DOCUMENTS, + "файл нельзя удалить: документ уже участвует в версии консолидации на согласовании", + ) + unbound_version_ids = sorted({version_id for version_id, _ in bound}) + await consolidation_documents.unbind_documents( + self._session, document_ids=[document.id for document in confirmed] + ) current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) 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") @@ -326,6 +341,7 @@ class PrimaryFilesService: object_type=OBJECT_TYPE, object_id=file_id, before_value=before, + after_value={"unbound_version_ids": unbound_version_ids}, ) async def _cards(self, files: list[StoredFile]) -> list[FileOut]: diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 918bfe0..7273dc5 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -16,11 +16,21 @@ import pytest from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.common.clock import utc_now from src.core.config import get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent, Comment +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity, RegionalOffice -from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus +from src.models.enums import ( + AuditAction, + AuditSection, + ConsolidationStatus, + DocumentStatus, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.models.statements import ( @@ -33,6 +43,7 @@ from src.services.result_mapping import process_once from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( make_company_group, + make_consolidation, make_document, make_entries_from_result, make_legal_entity, @@ -108,7 +119,7 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.before_value["status"] == "ERROR" assert event.before_value["job"]["id"] == str(job_id) assert event.before_value["job"]["error_code"] == "PARSE_ERROR" - assert event.after_value is None + assert event.after_value == {"unbound_version_ids": []} MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" @@ -248,7 +259,9 @@ async def test_confirmed_document_blocks_delete(seeded: AsyncSession, auth_clien seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK) - await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED) + confirmed = await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED) + confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id + await seeded.flush() file_id, job_id = file.id, job.id client = await auth_client(KA_RF) @@ -383,8 +396,11 @@ async def test_delete_rereads_state_after_locks( deletion = asyncio.create_task(client.delete(f"/files/{file_id}")) await _wait_for_lock_waiter(committed_sessionmaker) assert not deletion.done() + user_id = (await confirmer.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() await confirmer.execute( - update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED) + update(Document) + .where(Document.id == document_id) + .values(status=DocumentStatus.CONFIRMED, confirmed_by=user_id) ) response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS) @@ -441,3 +457,105 @@ async def test_mapping_loop_ignores_deleted_file( result_key = f"results/{file_id}/{job_id}/result.json" expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one() assert expires is not None + + +# --- автоподтверждённые документы (OQ-157, design D-4) --------------------------------------------------------------- + + +async def _auto_confirmed_in_versions( + session: AsyncSession, statuses: list[ConsolidationStatus] +) -> tuple[StoredFile, Document, list[ConsolidationVersion]]: + """ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60 + за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям.""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group) + other = await make_legal_entity(session, name="ООО Второе", group=group) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + versions = [] + for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False): + _, version = await make_consolidation( + session, + group=group, + year=2024, + quarter=quarter, + members=[entity, other], + initiator=initiator, + status=status, + ) + versions.append(version) + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document( + session, + file=file, + job=job, + account="60", + year=2024, + quarter=PeriodQuarter.Q2, + status=DocumentStatus.CONFIRMED, + is_cumulative=False, + ) + document.confirmed_at = utc_now() + for version in versions: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + return file, document, versions + + +async def test_delete_auto_confirmed_in_correction_unbinds( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION]) + file_id, version_id = file.id, version.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + assert ( + await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0 + ) + refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True) + assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION + [event] = await _delete_events(seeded) + assert event.after_value == {"unbound_version_ids": [str(version_id)]} + + +async def test_delete_blocked_if_any_bound_version_left_in_work( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята.""" + file, document, versions = await _auto_confirmed_in_versions( + seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF] + ) + file_id, document_id = file.id, document.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS" + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len( + versions + ) + assert await _delete_events(seeded) == [] + + +async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED) # confirmed_by NULL + file_id = file.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + [event] = await _delete_events(seeded) + assert event.after_value == {"unbound_version_ids": []} -- 2.47.2 From e7bef704cfb5c6cb083f9cf665873b72a84aca8a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:31:01 +0400 Subject: [PATCH 08/10] =?UTF-8?q?docs:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=87=D0=B8=D1=81=D1=82=D1=8B=D1=85=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B2?= =?UTF-8?q?=20CLAUDE.md=20=D0=B8=20README=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++++---- README.md | 7 ++++--- openspec/changes/auto-confirm-clean-documents/design.md | 4 ++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 18fc7f0..886a01e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,14 +76,14 @@ advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status` по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга: `pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции → - автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `domain/osv_mapping.py` (чистые функции: + автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции: `normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py` (`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при `NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга (`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер, `confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`, - `FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется + `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`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать); @@ -110,8 +110,8 @@ mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard` (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), `consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной - `POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` / - `on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты, + `POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` / + `on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты, экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»). Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие (`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду diff --git a/README.md b/README.md index a06bb15..b279f89 100644 --- a/README.md +++ b/README.md @@ -470,11 +470,12 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN - `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу (`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет; +- чистый документ (`RECOGNIZED`) подтверждается системой сразу в маппинге; `RECOGNIZED` остаётся у документов с замечаниями и у форм, не прошедших условия подтверждения (после правки формы автоподтверждения нет); - `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта в файле, строки без родителя, нарастающая по данным Q1; - для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо проверок парсера; -- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит); +- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157); - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; @@ -600,7 +601,7 @@ advisory-блокировкой. **Удаление.** `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`) +(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157) и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке «задания → файл → документы». @@ -840,7 +841,7 @@ email без учёта регистра проверяет только сер | `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` | | `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией | | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | -| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | +| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с документом, подтверждённым пользователем, или с автоподтверждённым документом в версии вне «в работе» (OQ-157) | | `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | | `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) | | `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) | diff --git a/openspec/changes/auto-confirm-clean-documents/design.md b/openspec/changes/auto-confirm-clean-documents/design.md index 6621e0d..0ec6c0a 100644 --- a/openspec/changes/auto-confirm-clean-documents/design.md +++ b/openspec/changes/auto-confirm-clean-documents/design.md @@ -109,3 +109,7 @@ | 5 | 2026-10-08 | Существующие `RECOGNIZED` не мигрируются | контур без продовых данных; миграция данных с аудитом без пользователя — лишний риск | на стенде старые документы подтверждаются кнопкой | | 6 | 2026-10-08 | Комментарий перехода версии — «автоподтверждение документа <тип> <счёт>», описание аудита — «автоподтверждение: без замечаний» | история версии и журнал объясняют, откуда переход без клика | тексты меняются без миграции | | 7 | 2026-10-08 | Удаление: проверка статусов версий только тех, к которым привязаны документы файла; `FILE_DELETE.after_value.unbound_version_ids` | симметрично `FILE_REPLACE.unbound_version_ids` | — | +| 8 | 2026-10-08 | Тест `form_confirmable` ломает строку 2200 (строгое соотношение), а не 2400 (соотношения с 2400 в `statement_checks.yaml` нежёсткие) | иначе тест не проверяет жёсткое нарушение | только тест | +| 9 | 2026-10-08 | Тесты автоподтверждения, маппинга и удаления берут `uploaded_by` у реального пользователя (FK `app_user`), а не случайный uuid (ошибка плана) | случайный uuid не проходит FK | нет | +| 10 | 2026-10-08 | В `tests/test_api_files_delete.py` тесты, имитирующие подтверждение пользователем (`test_confirmed_document_blocks_delete`, `test_delete_rereads_state_after_locks`), получили `confirmed_by` | иначе по правилу удаления (D-4) документ считается автоподтверждённым и удаление проходит | нет | +| 11 | 2026-10-08 | Импорт `auto_confirm` в `result_mapping._map_candidate` — локальный | цикл `documents` → `result_mapping` → `auto_confirm` → `statements` → `documents` | техдолг: вынести `form_confirmable`/`FORM_TYPES` в модуль без обратной зависимости | -- 2.47.2 From 8122205357c2014100a8b197bc12108d56fe2243 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:50:37 +0400 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20(I-1,=20M-1,=20M-2?= =?UTF-8?q?,=20M-4..M-6,=20T6.x)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 19 +++++++++++-------- api/src/api/endpoints/files/read.py | 2 +- api/src/services/primary_files.py | 2 +- api/src/services/statements.py | 2 +- api/tests/test_auto_confirm.py | 9 ++++++++- api/tests/test_result_mapping.py | 12 ++++++++++-- .../auto-confirm-clean-documents/design.md | 9 ++++++--- .../auto-confirm-clean-documents/proposal.md | 6 ++++-- 9 files changed, 43 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 886a01e..8882051 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,7 @@ advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status` по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга: `pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции → - автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции: + автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции: `normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py` (`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при `NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга diff --git a/README.md b/README.md index b279f89..5019e7c 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,9 @@ POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + rec → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm ``` +Чистый документ подтверждается самим маппингом (автоподтверждение, OQ-157); `POST /documents/{id}/confirm` нужен для +`NEEDS_CHECK` и для форм ББ/ОФР после правки. + Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg` (`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`) @@ -466,11 +469,10 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN `PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`. **Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на -тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, сразу `RECOGNIZED`). Условия для листа ОСВ: +тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, при маппинге `RECOGNIZED`, затем автоподтверждаются системой). Условия для листа ОСВ: - `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу (`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет; -- чистый документ (`RECOGNIZED`) подтверждается системой сразу в маппинге; `RECOGNIZED` остаётся у документов с замечаниями и у форм, не прошедших условия подтверждения (после правки формы автоподтверждения нет); - `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта в файле, строки без родителя, нарастающая по данным Q1; - для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо @@ -478,6 +480,8 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN - `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157); - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). +Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. + `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; `period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. @@ -524,7 +528,7 @@ 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`; для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` | +| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё `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}`) | @@ -601,9 +605,8 @@ advisory-блокировкой. **Удаление.** `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_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157) -и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке -«задания → файл → документы». +(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157). Нельзя также удалить файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке +«задания → файл → документы → advisory ГК → версии по id». ## Ограничения Среза 2c @@ -613,7 +616,7 @@ advisory-блокировкой. - Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены (Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы. - Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). - Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED`, + Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение; `locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается любой результат, проходящий схему `recognition-result`. - Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД» @@ -967,7 +970,7 @@ email без учёта регистра проверяет только сер Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с РЕАЛЬНЫМ tabular-parser: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS → tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий, -`RECOGNIZED`, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на +`CONFIRMED` автоподтверждением, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}` (объекту S3 ставится `expires_at`), БД снимается. diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 6205f84..6ac1a27 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -32,7 +32,7 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = { status.HTTP_409_CONFLICT: { "model": ErrorDetail, "description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый " - "документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS " + "документ привязан к версии вне корректировки данных) или FILE_SUPERSEDES_OTHERS " "(файл заменил более ранние версии)", }, } diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index df13a32..7454edf 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -318,7 +318,7 @@ class PrimaryFilesService: if any(status not in IN_WORK_STATUSES for _, status in bound): raise ConflictError( FILE_HAS_CONFIRMED_DOCUMENTS, - "файл нельзя удалить: документ уже участвует в версии консолидации на согласовании", + "файл нельзя удалить: документ привязан к версии консолидации вне корректировки данных", ) unbound_version_ids = sorted({version_id for version_id, _ in bound}) await consolidation_documents.unbind_documents( diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 9288c12..774aeb8 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -59,7 +59,7 @@ async def statement_hard_failures( async def form_confirmable(session: AsyncSession, document: Document) -> bool: - """Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): + """Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409.""" if document.document_type not in FORM_TYPES: diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 2fb4e37..4c731ea 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -5,6 +5,7 @@ import uuid from pathlib import Path import pytest +from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -143,10 +144,16 @@ async def test_file_without_uploader_binds_without_transition(seeded: AsyncSessi version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED) file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None) - await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + messages: list[str] = [] + sink = logger.add(lambda message: messages.append(str(message)), level="INFO") + try: + await auto_confirm(seeded, file=file, documents=[document], now=utc_now()) + finally: + logger.remove(sink) assert document.status is DocumentStatus.CONFIRMED assert await _bound(seeded, version.id) == {document.id} + assert any("Переход версии по подтверждению пропущен" in m and str(version.id) in m for m in messages) refreshed = await seeded.get(ConsolidationVersion, version.id, populate_existing=True) assert refreshed is not None and refreshed.status is ConsolidationStatus.INITIATED diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 84d28ed..973f7ab 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -1562,6 +1562,11 @@ async def test_mixed_file_confirms_clean_and_moves_version_once( assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)] history = await _history_of(maker, version_id) assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1 + # переход сделал именно автоподтверждение (T4.2): при перестановке с on_documents_mapped комментарий иной + assert history[-1].to_status is ConsolidationStatus.DATA_CORRECTION + assert history[-1].comment == "автоподтверждение документа OSV 60" + clean = next(d for d in await _documents(maker, file_id) if d.osv_account_code == "60") + assert await _bound_ids(maker, version_id) == {clean.id} # NEEDS_CHECK-документ 62 не привязан async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]): @@ -1657,8 +1662,9 @@ async def _audit_count(maker: async_sessionmaker[AsyncSession]) -> int: async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker: async_sessionmaker[AsyncSession]): """spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и - автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` оставляет таблицу - аудита неизменной.""" + автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` для документа + `NEEDS_CHECK` (у владельца нет ИНН) оставляет таблицу аудита неизменной. Автоподтверждение чистого документа пишет + `DOCUMENT_CONFIRM` — это проверяет отдельный тест.""" maker, storage = committed_sessionmaker, FakeStorage() file_id, job_id, _ = await _prepare( maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED @@ -1674,6 +1680,8 @@ async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker assert await process_once(maker, storage, _settings()) is True assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + [document] = await _documents(maker, file_id) + assert document.status is DocumentStatus.NEEDS_CHECK assert await _audit_count(maker) == before diff --git a/openspec/changes/auto-confirm-clean-documents/design.md b/openspec/changes/auto-confirm-clean-documents/design.md index 0ec6c0a..c8dd7c3 100644 --- a/openspec/changes/auto-confirm-clean-documents/design.md +++ b/openspec/changes/auto-confirm-clean-documents/design.md @@ -58,10 +58,10 @@ ### D-4. Удаление: только автоподтверждённое и только до согласования `PrimaryFilesService.delete` под существующими блокировками (задания → файл → документы) проверяет: если у файла есть `CONFIRMED` документ с `confirmed_by IS NOT NULL` — 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (как сейчас). Если все -`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ (`lock_versions_for_entity` по всем статусам, где -есть привязки этих документов; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая +`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ «в работе» (`lock_versions_for_entity` берёт только `IN_WORK_STATUSES`; версии вне +«в работе» читаются без блокировки: удаление из них запрещено, возврата в «в работе» нет; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая версия с привязкой в `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` с -текстом «документ уже участвует в версии на согласовании». Затем привязки снимаются (`consolidation_document`), файл +текстом «документ привязан к версии консолидации вне корректировки данных». Затем привязки снимаются (`consolidation_document`), файл удаляется как сейчас; `FILE_DELETE.after_value = {unbound_version_ids: [...]}` (по возрастанию; пусто — пустой список), `before_value` — карточка файла, как сейчас. Статус версии не меняется (свод — чистая функция, пересчитается). `FILE_SUPERSEDES_OTHERS` — без изменений. @@ -113,3 +113,6 @@ | 9 | 2026-10-08 | Тесты автоподтверждения, маппинга и удаления берут `uploaded_by` у реального пользователя (FK `app_user`), а не случайный uuid (ошибка плана) | случайный uuid не проходит FK | нет | | 10 | 2026-10-08 | В `tests/test_api_files_delete.py` тесты, имитирующие подтверждение пользователем (`test_confirmed_document_blocks_delete`, `test_delete_rereads_state_after_locks`), получили `confirmed_by` | иначе по правилу удаления (D-4) документ считается автоподтверждённым и удаление проходит | нет | | 11 | 2026-10-08 | Импорт `auto_confirm` в `result_mapping._map_candidate` — локальный | цикл `documents` → `result_mapping` → `auto_confirm` → `statements` → `documents` | техдолг: вынести `form_confirmable`/`FORM_TYPES` в модуль без обратной зависимости | +| 12 | 2026-10-08 | `make check-full` запускается один раз в конце, после финального ревью и fix-волны (а не внутри Task 6 плана) | не гонять полную проверку дважды | если check-full что-то найдёт — правка после ревью | +| 13 | 2026-10-08 | M-3 (гонка в `bound_versions` при будущем маршруте «вернуть на доработку» Среза 7) и вынос `form_confirmable` из цикла импортов оставлены техдолгом (follow-up) | сейчас возврата версии в «в работе» нет, цикл импортов обойдён локальным импортом | при добавлении возврата на доработку надо отвечать 409 для версий, не попавших в множество, заблокированное `lock_versions_for_entity` | +| 14 | 2026-10-08 | Отменённая/отклонённая/архивная версия с привязкой автоподтверждённого документа блокирует удаление файла (по спеке «вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION»), привязки при отмене версии не снимаются | буквальное чтение спеки | файл из отменённой версии удалить нельзя — вопрос владельцу | diff --git a/openspec/changes/auto-confirm-clean-documents/proposal.md b/openspec/changes/auto-confirm-clean-documents/proposal.md index 919db8d..cf957ff 100644 --- a/openspec/changes/auto-confirm-clean-documents/proposal.md +++ b/openspec/changes/auto-confirm-clean-documents/proposal.md @@ -49,7 +49,9 @@ DBML и контракты обмена не меняются. - Код: `services/result_mapping.py` (`_map_candidate`), `services/consolidation_documents.py` (`on_document_confirmed` с внешним `changed_by`/`comment`, новый `auto_confirm`), `services/documents.py` (общая - проверка условий формы), `services/primary_files.py` (`delete`), `repositories/files.py` (снятие привязок). -- Спеки: `osv-result-mapping`, `documents-api`, `primary-files`, `audit-trail`. + проверка условий формы), `services/primary_files.py` (`delete`; привязки снимает существующий + `consolidation_documents.unbind_documents`). +- Спеки (дельты): `osv-result-mapping`, `primary-files`, `audit-trail`. Ручной путь `POST /documents/{id}/confirm` не + меняется, поэтому дельты `documents-api` нет. - Поведение для фронта: чистые документы приходят сразу `CONFIRMED` («Загружен»); `confirmed_by = null` — признак автоподтверждения. -- 2.47.2 From 5f41550a3b4d5881557573078e7b557f6f48e14a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:52:59 +0400 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20README=20=E2=80=94=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=81=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=20(OQ-157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5019e7c..642d517 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,7 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD → публикатор (outbox) → NATS recognition.tabular.request → tabular-parser (читает S3, пишет results/…) → recognition.result → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED) - → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm + → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm (только для NEEDS_CHECK и форм после правки) ``` Чистый документ подтверждается самим маппингом (автоподтверждение, OQ-157); `POST /documents/{id}/confirm` нужен для @@ -625,8 +625,10 @@ advisory-блокировкой. - ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу. - ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга, поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца). -- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы, а их файл удалить - нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). +- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы; файл с документом, подтверждённым + пользователем, удалить нельзя, файл с автоподтверждённым документом — можно, пока все версии с его привязками в + `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (привязки снимаются; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; OQ-110, + уточнено OQ-157); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). - Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства. @@ -702,7 +704,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`). - **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`) и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются. -- **Файл с документом в версии удалить нельзя** (привязываются только `CONFIRMED`, а их файл удалять запрещено — OQ-110). +- **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157). - `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120). ### Чек-лист -- 2.47.2