Compare commits
10 Commits
d8bf5b22dc
...
5f41550a3b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f41550a3b | ||
|
|
8122205357 | ||
|
|
e7bef704cf | ||
|
|
256ddcb34a | ||
|
|
3830e718f4 | ||
|
|
b48d9e6f4d | ||
|
|
588c098838 | ||
|
|
ff65f2108b | ||
|
|
d9324f2a38 | ||
|
|
3623358fb4 |
@ -76,14 +76,14 @@
|
|||||||
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
|
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
|
||||||
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
|
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
|
||||||
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
|
`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`
|
`normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py`
|
||||||
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
|
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
|
||||||
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга
|
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга
|
||||||
(`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
|
(`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
|
||||||
`confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе
|
`confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе
|
||||||
взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`,
|
взаимоблокировки; автозамена дополнительно берёт 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` не хранится.
|
(OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится.
|
||||||
- **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые
|
- **Формы ББ/ОФР (Срез 3b)** — `map_file` (`services/result_mapping.py`) мапит и формы ББ/ОФР: `domain/statement_mapping.py` (чистые
|
||||||
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
|
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
|
||||||
@ -110,8 +110,8 @@
|
|||||||
mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard`
|
mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard`
|
||||||
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),
|
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),
|
||||||
`consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной
|
`consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной
|
||||||
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` /
|
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` /
|
||||||
`on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
|
`on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
|
||||||
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
|
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
|
||||||
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
|
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
|
||||||
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду
|
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду
|
||||||
|
|||||||
32
README.md
32
README.md
@ -420,9 +420,12 @@ 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
|
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/…)
|
→ публикатор (outbox) → NATS recognition.tabular.request → tabular-parser (читает S3, пишет results/…)
|
||||||
→ recognition.result → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED)
|
→ 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` нужен для
|
||||||
|
`NEEDS_CHECK` и для форм ББ/ОФР после правки.
|
||||||
|
|
||||||
Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости
|
Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости
|
||||||
пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg`
|
пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg`
|
||||||
(`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`)
|
(`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`)
|
||||||
@ -466,7 +469,7 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
|
|||||||
`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`.
|
`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`.
|
||||||
|
|
||||||
**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
|
**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
|
||||||
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, сразу `RECOGNIZED`). Условия для листа ОСВ:
|
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, при маппинге `RECOGNIZED`, затем автоподтверждаются системой). Условия для листа ОСВ:
|
||||||
|
|
||||||
- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу
|
- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу
|
||||||
(`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет;
|
(`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет;
|
||||||
@ -474,9 +477,11 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
|
|||||||
в файле, строки без родителя, нарастающая по данным Q1;
|
в файле, строки без родителя, нарастающая по данным Q1;
|
||||||
- для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо
|
- для формы ББ/ОФР условия `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` файл).
|
- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл).
|
||||||
|
|
||||||
|
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
|
||||||
|
|
||||||
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
|
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
|
||||||
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
|
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
|
||||||
|
|
||||||
@ -523,7 +528,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
|||||||
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
|
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
|
||||||
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` |
|
| `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` |
|
| `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 (см. «Формы ББ/ОФР») |
|
| `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
|
||||||
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
|
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
|
||||||
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
|
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
|
||||||
@ -600,9 +605,8 @@ advisory-блокировкой.
|
|||||||
|
|
||||||
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
|
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
|
||||||
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`
|
(`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` файл удалить можно. Блокировки — в порядке
|
||||||
и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
|
«задания → файл → документы → advisory ГК → версии по id».
|
||||||
«задания → файл → документы».
|
|
||||||
|
|
||||||
## Ограничения Среза 2c
|
## Ограничения Среза 2c
|
||||||
|
|
||||||
@ -612,7 +616,7 @@ advisory-блокировкой.
|
|||||||
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
|
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
|
||||||
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
|
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
|
||||||
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
|
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
|
||||||
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED`,
|
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение;
|
||||||
`locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается
|
`locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается
|
||||||
любой результат, проходящий схему `recognition-result`.
|
любой результат, проходящий схему `recognition-result`.
|
||||||
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
|
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
|
||||||
@ -621,8 +625,10 @@ advisory-блокировкой.
|
|||||||
- ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
|
- ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
|
||||||
- ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга,
|
- ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга,
|
||||||
поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
|
поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
|
||||||
- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы, а их файл удалить
|
- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы; файл с документом, подтверждённым
|
||||||
нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d).
|
пользователем, удалить нельзя, файл с автоподтверждённым документом — можно, пока все версии с его привязками в
|
||||||
|
`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` до ручного вмешательства.
|
при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства.
|
||||||
|
|
||||||
@ -698,7 +704,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
|
|||||||
(подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`).
|
(подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`).
|
||||||
- **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`)
|
- **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`)
|
||||||
и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются.
|
и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются.
|
||||||
- **Файл с документом в версии удалить нельзя** (привязываются только `CONFIRMED`, а их файл удалять запрещено — OQ-110).
|
- **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157).
|
||||||
- `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
|
- `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
|
||||||
|
|
||||||
### Чек-лист
|
### Чек-лист
|
||||||
@ -840,7 +846,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` |
|
| `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` |
|
||||||
| `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией |
|
| `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией |
|
||||||
| `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) |
|
| `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 (истёк или пропал) |
|
| `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) |
|
||||||
| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) |
|
| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement`: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) |
|
||||||
| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) |
|
| `DOCUMENT_READONLY` | 409 | правка/акцепт формы: форма уже `CONFIRMED` (исправление — новым файлом) |
|
||||||
@ -966,7 +972,7 @@ email без учёта регистра проверяет только сер
|
|||||||
Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с
|
Тест `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: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS →
|
||||||
tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий,
|
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}`
|
отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}`
|
||||||
(объекту S3 ставится `expires_at`), БД снимается.
|
(объекту S3 ставится `expires_at`), БД снимается.
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,8 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|||||||
**NOT_FOUND,
|
**NOT_FOUND,
|
||||||
status.HTTP_409_CONFLICT: {
|
status.HTTP_409_CONFLICT: {
|
||||||
"model": ErrorDetail,
|
"model": ErrorDetail,
|
||||||
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS "
|
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый "
|
||||||
|
"документ привязан к версии вне корректировки данных) или FILE_SUPERSEDES_OTHERS "
|
||||||
"(файл заменил более ранние версии)",
|
"(файл заменил более ранние версии)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
75
api/src/services/auto_confirm.py
Normal file
75
api/src/services/auto_confirm.py
Normal file
@ -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
|
||||||
@ -39,10 +39,12 @@ from src.services.audit import AuditService
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"bind_confirmed",
|
"bind_confirmed",
|
||||||
|
"bound_versions",
|
||||||
"fits",
|
"fits",
|
||||||
"on_document_confirmed",
|
"on_document_confirmed",
|
||||||
"on_documents_mapped",
|
"on_documents_mapped",
|
||||||
"on_documents_superseded",
|
"on_documents_superseded",
|
||||||
|
"unbind_documents",
|
||||||
"unbind_entity",
|
"unbind_entity",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -197,18 +199,54 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_
|
|||||||
return _rowcount(result)
|
return _rowcount(result)
|
||||||
|
|
||||||
|
|
||||||
async def on_document_confirmed(
|
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,
|
session: AsyncSession,
|
||||||
audit: AuditService,
|
audit: AuditService,
|
||||||
*,
|
*,
|
||||||
document: Document,
|
document: Document,
|
||||||
user_id: uuid.UUID,
|
changed_by: uuid.UUID | None,
|
||||||
now: datetime.datetime,
|
now: datetime.datetime,
|
||||||
|
comment: str | None = None,
|
||||||
) -> list[uuid.UUID]:
|
) -> list[uuid.UUID]:
|
||||||
"""Хук подтверждения (spec «Подтверждение документа»): привязывает уже `CONFIRMED` документ ко всем версиям «в
|
"""Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»):
|
||||||
работе», которым он подходит, заменяя привязанный документ того же ключа (R-1); версия из `INITIATED` /
|
привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ
|
||||||
`DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` (`changed_by = user_id`, D-6). Возвращает id версий,
|
того же ключа (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 ГК
|
Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК
|
||||||
(создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
|
(создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
|
||||||
@ -227,15 +265,23 @@ async def on_document_confirmed(
|
|||||||
continue
|
continue
|
||||||
await _bind(session, version.id, document)
|
await _bind(session, version.id, document)
|
||||||
if version.status in MOVES_TO_CORRECTION:
|
if version.status in MOVES_TO_CORRECTION:
|
||||||
await transition(
|
if changed_by is None:
|
||||||
session,
|
logger.info(
|
||||||
audit,
|
"Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). "
|
||||||
version=version,
|
"version_id={version_id}, document_id={document_id}",
|
||||||
to=ConsolidationStatus.DATA_CORRECTION,
|
version_id=version.id,
|
||||||
changed_by=user_id,
|
document_id=document.id,
|
||||||
comment=None,
|
)
|
||||||
now=now,
|
else:
|
||||||
)
|
await transition(
|
||||||
|
session,
|
||||||
|
audit,
|
||||||
|
version=version,
|
||||||
|
to=ConsolidationStatus.DATA_CORRECTION,
|
||||||
|
changed_by=changed_by,
|
||||||
|
comment=comment,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
bound.append(version.id)
|
bound.append(version.id)
|
||||||
return bound
|
return bound
|
||||||
|
|
||||||
|
|||||||
@ -209,7 +209,7 @@ class DocumentService:
|
|||||||
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
||||||
Для не-формы и документа без шапки — `None`, условий нет.
|
Для не-формы и документа без шапки — `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:
|
if document.document_type not in FORM_TYPES:
|
||||||
return None
|
return None
|
||||||
@ -218,7 +218,7 @@ class DocumentService:
|
|||||||
return None
|
return None
|
||||||
if document.period_year is None or document.period_quarter is None:
|
if document.period_year is None or document.period_quarter is None:
|
||||||
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
|
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:
|
if failures:
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
CHECKS_NOT_PASSED,
|
CHECKS_NOT_PASSED,
|
||||||
@ -273,7 +273,7 @@ class DocumentService:
|
|||||||
meta.reviewed_at = now
|
meta.reviewed_at = now
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
await consolidation_documents.on_document_confirmed(
|
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(
|
await self._audit.record(
|
||||||
action=AuditAction.DOCUMENT_CONFIRM,
|
action=AuditAction.DOCUMENT_CONFIRM,
|
||||||
|
|||||||
@ -54,6 +54,7 @@ from src.repositories import directories as directories_repo
|
|||||||
from src.repositories import files as files_repo
|
from src.repositories import files as files_repo
|
||||||
from src.schemas.common import PageParams
|
from src.schemas.common import PageParams
|
||||||
from src.schemas.files import FileOut
|
from src.schemas.files import FileOut
|
||||||
|
from src.services import consolidation_documents
|
||||||
from src.services.audit import AuditService
|
from src.services.audit import AuditService
|
||||||
from src.services.consolidation_guard import VERSION_NOT_FOUND
|
from src.services.consolidation_guard import VERSION_NOT_FOUND
|
||||||
from src.services.documents import file_out
|
from src.services.documents import file_out
|
||||||
@ -283,9 +284,11 @@ class PrimaryFilesService:
|
|||||||
|
|
||||||
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
|
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
|
||||||
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
|
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
|
||||||
под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`,
|
под блокировками, и 409 проверяются после них, в порядке: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ,
|
||||||
`FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл,
|
подтверждённый пользователем; `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка
|
||||||
исчезнувший, пока ждали блокировку, — 404.
|
версий не рвётся); `FILE_HAS_CONFIRMED_DOCUMENTS` — автоподтверждённый документ привязан к версии вне «в
|
||||||
|
работе» (OQ-157). Привязки автоподтверждённых документов снимаются (после блокировки advisory ГК → версии),
|
||||||
|
id версий — в `after_value.unbound_version_ids`. Файл, исчезнувший, пока ждали блокировку, — 404.
|
||||||
|
|
||||||
Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
|
Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
|
||||||
и `stored_file`; `s3_object` файла и результатов заданий
|
и `stored_file`; `s3_object` файла и результатов заданий
|
||||||
@ -300,15 +303,27 @@ class PrimaryFilesService:
|
|||||||
if file is None: # удалён параллельно, пока ждали блокировку
|
if file is None: # удалён параллельно, пока ждали блокировку
|
||||||
raise NotFoundError(FILE_NOT_FOUND)
|
raise NotFoundError(FILE_NOT_FOUND)
|
||||||
documents = await files_repo.lock_file_documents(self._session, file_id)
|
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(
|
raise ConflictError(
|
||||||
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы"
|
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
|
||||||
)
|
)
|
||||||
if await files_repo.supersedes_others(self._session, file_id):
|
if await files_repo.supersedes_others(self._session, file_id):
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
FILE_SUPERSEDES_OTHERS,
|
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)
|
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])
|
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")
|
before = file_out(file, current, documents, counts).model_dump(mode="json")
|
||||||
@ -326,6 +341,7 @@ class PrimaryFilesService:
|
|||||||
object_type=OBJECT_TYPE,
|
object_type=OBJECT_TYPE,
|
||||||
object_id=file_id,
|
object_id=file_id,
|
||||||
before_value=before,
|
before_value=before,
|
||||||
|
after_value={"unbound_version_ids": unbound_version_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
|
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
|
||||||
|
|||||||
@ -12,7 +12,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
|
|||||||
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
|
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
|
||||||
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
|
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
|
||||||
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ),
|
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 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 не проходит схему, нет документов)
|
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов)
|
||||||
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
|
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
|
||||||
@ -895,10 +896,15 @@ async def _map_candidate(
|
|||||||
index = await CounterpartyIndex.load(session)
|
index = await CounterpartyIndex.load(session)
|
||||||
now = clock()
|
now = clock()
|
||||||
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
|
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
|
||||||
# Переход версий (D-6) — ПОСЛЕ `map_file`, а значит после автозамены (`supersede_previous` берёт advisory ЮЛ →
|
# После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
|
||||||
# старые файлы → advisory ГК → версии): версии блокируются последними, порядок «задание → файл → документы →
|
# блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
|
||||||
# версии» (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 = 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)
|
await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}",
|
"Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}",
|
||||||
|
|||||||
@ -47,6 +47,31 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
|
|||||||
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
|
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:
|
||||||
|
"""Условия подтверждения формы ББ/ОФР без исключений (§7, 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:
|
class StatementService:
|
||||||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
@ -107,11 +132,7 @@ class StatementService:
|
|||||||
|
|
||||||
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
|
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
|
||||||
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
|
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
|
||||||
if meta.checks_accepted_at is not None:
|
return await statement_hard_failures(self._session, document, meta)
|
||||||
return ()
|
|
||||||
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
|
|
||||||
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
|
|
||||||
return tuple(report.failed)
|
|
||||||
|
|
||||||
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
|
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
|
||||||
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
|
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
|
||||||
|
|||||||
@ -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)
|
assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True)
|
||||||
# MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой
|
# MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой
|
||||||
assert brief["counterparty_check"] == "MATCH"
|
assert brief["counterparty_check"] == "MATCH"
|
||||||
assert brief["status"] == "RECOGNIZED"
|
assert brief["status"] == "CONFIRMED" # чистая ОСВ подтверждена автоматически (OQ-157)
|
||||||
|
|
||||||
document = (await client.get(f"/documents/{brief['id']}")).json()
|
document = (await client.get(f"/documents/{brief['id']}")).json()
|
||||||
assert document["file_id"] == str(file_id)
|
assert document["file_id"] == str(file_id)
|
||||||
assert document["legal_entity_id"] == str(entity.id)
|
assert document["legal_entity_id"] == str(entity.id)
|
||||||
assert (document["period_year"], document["period_quarter"]) == (2024, "Q4")
|
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()
|
entries = (await client.get(f"/documents/{brief['id']}/entries")).json()
|
||||||
assert entries["total"] == ENTRY_ROWS
|
assert entries["total"] == ENTRY_ROWS
|
||||||
|
|||||||
@ -16,11 +16,21 @@ import pytest
|
|||||||
from sqlalchemy import func, select, text, update
|
from sqlalchemy import func, select, text, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from src.common.clock import utc_now
|
||||||
from src.core.config import get_settings
|
from src.core.config import get_settings
|
||||||
from src.models.access import AppUser
|
from src.models.access import AppUser
|
||||||
from src.models.audit_ex import AuditEvent, Comment
|
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.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.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
from src.models.statements import (
|
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.conftest import AuthClientFactory, FakeStorage
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
make_company_group,
|
make_company_group,
|
||||||
|
make_consolidation,
|
||||||
make_document,
|
make_document,
|
||||||
make_entries_from_result,
|
make_entries_from_result,
|
||||||
make_legal_entity,
|
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["status"] == "ERROR"
|
||||||
assert event.before_value["job"]["id"] == str(job_id)
|
assert event.before_value["job"]["id"] == str(job_id)
|
||||||
assert event.before_value["job"]["error_code"] == "PARSE_ERROR"
|
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"
|
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
|
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="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
|
file_id, job_id = file.id, job.id
|
||||||
client = await auth_client(KA_RF)
|
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}"))
|
deletion = asyncio.create_task(client.delete(f"/files/{file_id}"))
|
||||||
await _wait_for_lock_waiter(committed_sessionmaker)
|
await _wait_for_lock_waiter(committed_sessionmaker)
|
||||||
assert not deletion.done()
|
assert not deletion.done()
|
||||||
|
user_id = (await confirmer.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
|
||||||
await confirmer.execute(
|
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)
|
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"
|
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()
|
expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one()
|
||||||
assert expires is not None
|
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": []}
|
||||||
|
|||||||
225
api/tests/test_auto_confirm.py
Normal file
225
api/tests/test_auto_confirm.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"""Автоподтверждение чистых документов (osv-result-mapping «Автоподтверждение чистого документа»;
|
||||||
|
OQ-157; design D-1–D-3)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@ -473,7 +473,7 @@ async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession
|
|||||||
user = await _user(seeded)
|
user = await _user(seeded)
|
||||||
|
|
||||||
bound = await consolidation_documents.on_document_confirmed(
|
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])
|
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()
|
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
|
document.confirmed_at = now
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return await consolidation_documents.on_document_confirmed(
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
83
api/tests/test_form_confirmable.py
Normal file
83
api/tests/test_form_confirmable.py
Normal file
@ -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
|
||||||
@ -49,6 +49,7 @@ from src.models.enums import (
|
|||||||
from src.models.files import Document, RecognitionJob, StoredFile
|
from src.models.files import Document, RecognitionJob, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
from src.repositories.counterparties import CounterpartyIndex
|
from src.repositories.counterparties import CounterpartyIndex
|
||||||
|
from src.services import consolidation_documents
|
||||||
from src.services import result_mapping as rm
|
from src.services import result_mapping as rm
|
||||||
from src.services.recognition_exchange import expire_deadlines, handle_message
|
from src.services.recognition_exchange import expire_deadlines, handle_message
|
||||||
from src.services.result_mapping import (
|
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.is_cumulative is True
|
||||||
assert document.locator == "sheet:Лист_1"
|
assert document.locator == "sheet:Лист_1"
|
||||||
assert document.legal_entity_id == entity_id
|
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.counterparty_check is DocumentCheckResult.MATCH
|
||||||
assert document.period_check is DocumentCheckResult.UNKNOWN
|
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
|
assert document.status is DocumentStatus.NEEDS_CHECK
|
||||||
else:
|
else:
|
||||||
assert warnings == []
|
assert warnings == []
|
||||||
assert document.status is DocumentStatus.RECOGNIZED
|
assert document.status is DocumentStatus.CONFIRMED # чистый → автоподтверждение (OQ-157)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("job_status", "expected"),
|
("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(
|
async def test_clean_result(
|
||||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||||
job_status: RecognitionStatus,
|
job_status: RecognitionStatus,
|
||||||
expected: DocumentStatus,
|
expected: DocumentStatus,
|
||||||
):
|
):
|
||||||
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`;
|
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `CONFIRMED` (автоподтверждение,
|
||||||
|
OQ-157), `MATCH`, `UNKNOWN`;
|
||||||
задание `needs_check` → `NEEDS_CHECK`."""
|
задание `needs_check` → `NEEDS_CHECK`."""
|
||||||
maker, storage = committed_sessionmaker, FakeStorage()
|
maker, storage = committed_sessionmaker, FakeStorage()
|
||||||
file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status)
|
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(
|
@pytest.mark.parametrize(
|
||||||
("job_status", "moved"),
|
("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(
|
async def test_mapping_moves_documents_processing_version_only_for_needs_check(
|
||||||
committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool
|
committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool
|
||||||
):
|
):
|
||||||
"""«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии
|
"""«Предупреждение маппинга двигает статус» и «Документ 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()
|
maker, storage = committed_sessionmaker, FakeStorage()
|
||||||
entity_id, version_id, user_id = await _version_in(maker)
|
entity_id, version_id, user_id = await _version_in(maker)
|
||||||
file_id, _, _ = await _prepare(
|
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
|
assert await process_once(maker, storage, _settings()) is True
|
||||||
|
|
||||||
[document] = await _documents(maker, file_id)
|
[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:
|
async with maker() as session:
|
||||||
version = await session.get(ConsolidationVersion, version_id)
|
version = await session.get(ConsolidationVersion, version_id)
|
||||||
assert version is not None
|
assert version is not None
|
||||||
history = list(
|
history = await _history_of(maker, version_id)
|
||||||
(
|
|
||||||
await session.execute(
|
|
||||||
select(ConsolidationStatusHistory)
|
|
||||||
.where(ConsolidationStatusHistory.version_id == version_id)
|
|
||||||
.order_by(ConsolidationStatusHistory.changed_at)
|
|
||||||
)
|
|
||||||
).scalars()
|
|
||||||
)
|
|
||||||
bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one()
|
bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one()
|
||||||
assert bindings == 0
|
|
||||||
if moved:
|
if moved:
|
||||||
|
assert bindings == 0
|
||||||
assert version.status is ConsolidationStatus.DATA_CORRECTION
|
assert version.status is ConsolidationStatus.DATA_CORRECTION
|
||||||
last = history[-1]
|
last = history[-1]
|
||||||
assert (last.from_status, last.to_status, last.changed_by) == (
|
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,
|
user_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING
|
# чистый документ подтверждён системой (OQ-157): привязан, версия перешла от загрузившего с комментарием
|
||||||
assert len(history) == FACTORY_HISTORY # создание и переход фабрики в DOCUMENTS_PROCESSING; новых записей нет
|
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]):
|
async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||||||
@ -1479,6 +1482,147 @@ async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: a
|
|||||||
assert version.status is ConsolidationStatus.INITIATED
|
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
|
||||||
|
# переход сделал именно автоподтверждение (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]):
|
||||||
|
"""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(
|
async def test_mapping_without_uploader_skips_transition_without_error(
|
||||||
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
committed_sessionmaker: async_sessionmaker[AsyncSession],
|
||||||
):
|
):
|
||||||
@ -1518,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]):
|
async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||||||
"""spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и
|
"""spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и
|
||||||
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` оставляет таблицу
|
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` для документа
|
||||||
аудита неизменной."""
|
`NEEDS_CHECK` (у владельца нет ИНН) оставляет таблицу аудита неизменной. Автоподтверждение чистого документа пишет
|
||||||
|
`DOCUMENT_CONFIRM` — это проверяет отдельный тест."""
|
||||||
maker, storage = committed_sessionmaker, FakeStorage()
|
maker, storage = committed_sessionmaker, FakeStorage()
|
||||||
file_id, job_id, _ = await _prepare(
|
file_id, job_id, _ = await _prepare(
|
||||||
maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED
|
maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED
|
||||||
@ -1535,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 process_once(maker, storage, _settings()) is True
|
||||||
|
|
||||||
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
|
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
|
assert await _audit_count(maker) == before
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1189
docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md
Normal file
1189
docs/superpowers/plans/2026-10-08-auto-confirm-clean-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-08
|
||||||
118
openspec/changes/auto-confirm-clean-documents/design.md
Normal file
118
openspec/changes/auto-confirm-clean-documents/design.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# 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` берёт только `IN_WORK_STATUSES`; версии вне
|
||||||
|
«в работе» читаются без блокировки: удаление из них запрещено, возврата в «в работе» нет; порядок «… → документы → 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` | — |
|
||||||
|
| 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` в модуль без обратной зависимости |
|
||||||
|
| 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»), привязки при отмене версии не снимаются | буквальное чтение спеки | файл из отменённой версии удалить нельзя — вопрос владельцу |
|
||||||
57
openspec/changes/auto-confirm-clean-documents/proposal.md
Normal file
57
openspec/changes/auto-confirm-clean-documents/proposal.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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`; привязки снимает существующий
|
||||||
|
`consolidation_documents.unbind_documents`).
|
||||||
|
- Спеки (дельты): `osv-result-mapping`, `primary-files`, `audit-trail`. Ручной путь `POST /documents/{id}/confirm` не
|
||||||
|
меняется, поэтому дельты `documents-api` нет.
|
||||||
|
- Поведение для фронта: чистые документы приходят сразу `CONFIRMED` («Загружен»); `confirmed_by = null` — признак
|
||||||
|
автоподтверждения.
|
||||||
@ -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 = «автоподтверждение: без замечаний»`
|
||||||
@ -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 = «автоподтверждение документа <document_type> <osv_account_code>»` (счёт — только у
|
||||||
|
ОСВ). Если `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` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате)
|
||||||
@ -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 = []`
|
||||||
Loading…
x
Reference in New Issue
Block a user