fix: правки по финальному ревью автоподтверждения (I-1, M-1, M-2, M-4..M-6, T6.x)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-08 17:50:37 +04:00
parent e7bef704cf
commit 8122205357
9 changed files with 43 additions and 20 deletions

View File

@ -76,7 +76,7 @@
advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status`
по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга:
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции:
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `services/auto_confirm.py` (автоподтверждение чистых документов в транзакции маппинга: `_map_candidate`: `map_file` → автозамена → `auto_confirm` → `on_documents_mapped`; `confirmed_by` NULL, `DOCUMENT_CONFIRM` без пользователя, переход версии — от `uploaded_by`; OQ-157), `domain/osv_mapping.py` (чистые функции:
`normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py`
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга

View File

@ -423,6 +423,9 @@ POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + rec
→ автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm
```
Чистый документ подтверждается самим маппингом (автоподтверждение, OQ-157); `POST /documents/{id}/confirm` нужен для
`NEEDS_CHECK` и для форм ББ/ОФР после правки.
Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости
пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg`
(`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`)
@ -466,11 +469,10 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`.
**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, сразу `RECOGNIZED`). Условия для листа ОСВ:
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, при маппинге `RECOGNIZED`, затем автоподтверждаются системой). Условия для листа ОСВ:
- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу
(`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет;
- чистый документ (`RECOGNIZED`) подтверждается системой сразу в маппинге; `RECOGNIZED` остаётся у документов с замечаниями и у форм, не прошедших условия подтверждения (после правки формы автоподтверждения нет);
- `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта
в файле, строки без родителя, нарастающая по данным Q1;
- для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо
@ -478,6 +480,8 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157);
- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл).
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
@ -524,7 +528,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` |
| `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` |
| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` |
| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` |
| `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
@ -601,9 +605,8 @@ advisory-блокировкой.
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157)
и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы».
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157). Нельзя также удалить файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы → advisory ГК → версии по id».
## Ограничения Среза 2c
@ -613,7 +616,7 @@ advisory-блокировкой.
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED`,
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение;
`locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается
любой результат, проходящий схему `recognition-result`.
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
@ -967,7 +970,7 @@ email без учёта регистра проверяет только сер
Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с
РЕАЛЬНЫМ tabular-parser: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS →
tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий,
`RECOGNIZED`, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на
`CONFIRMED` автоподтверждением, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на
отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}`
(объекту S3 ставится `expires_at`), БД снимается.

View File

@ -32,7 +32,7 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый "
"документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS "
"документ привязан к версии вне корректировки данных) или FILE_SUPERSEDES_OTHERS "
"(файл заменил более ранние версии)",
},
}

View File

@ -318,7 +318,7 @@ class PrimaryFilesService:
if any(status not in IN_WORK_STATUSES for _, status in bound):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS,
"файл нельзя удалить: документ уже участвует в версии консолидации на согласовании",
"файл нельзя удалить: документ привязан к версии консолидации вне корректировки данных",
)
unbound_version_ids = sorted({version_id for version_id, _ in bound})
await consolidation_documents.unbind_documents(

View File

@ -59,7 +59,7 @@ async def statement_hard_failures(
async def form_confirmable(session: AsyncSession, document: Document) -> bool:
"""Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
"""Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
if document.document_type not in FORM_TYPES:

View File

@ -5,6 +5,7 @@ import uuid
from pathlib import Path
import pytest
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -143,10 +144,16 @@ async def test_file_without_uploader_binds_without_transition(seeded: AsyncSessi
version = await _version(seeded, group, entities, ConsolidationStatus.INITIATED)
file, [document] = await _file_with(seeded, entities[0], [DocumentStatus.RECOGNIZED], None)
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

View File

@ -1562,6 +1562,11 @@ async def test_mixed_file_confirms_clean_and_moves_version_once(
assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)]
history = await _history_of(maker, version_id)
assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1
# переход сделал именно автоподтверждение (T4.2): при перестановке с on_documents_mapped комментарий иной
assert history[-1].to_status is ConsolidationStatus.DATA_CORRECTION
assert history[-1].comment == "автоподтверждение документа OSV 60"
clean = next(d for d in await _documents(maker, file_id) if d.osv_account_code == "60")
assert await _bound_ids(maker, version_id) == {clean.id} # NEEDS_CHECK-документ 62 не привязан
async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]):
@ -1657,8 +1662,9 @@ async def _audit_count(maker: async_sessionmaker[AsyncSession]) -> int:
async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` оставляет таблицу
аудита неизменной."""
автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` для документа
`NEEDS_CHECK` (у владельца нет ИНН) оставляет таблицу аудита неизменной. Автоподтверждение чистого документа пишет
`DOCUMENT_CONFIRM` — это проверяет отдельный тест."""
maker, storage = committed_sessionmaker, FakeStorage()
file_id, job_id, _ = await _prepare(
maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED
@ -1674,6 +1680,8 @@ async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker
assert await process_once(maker, storage, _settings()) is True
assert (await _file(maker, file_id)).status is FileStatus.PROCESSED
[document] = await _documents(maker, file_id)
assert document.status is DocumentStatus.NEEDS_CHECK
assert await _audit_count(maker) == before

View File

@ -58,10 +58,10 @@
### D-4. Удаление: только автоподтверждённое и только до согласования
`PrimaryFilesService.delete` под существующими блокировками (задания → файл → документы) проверяет: если у файла есть
`CONFIRMED` документ с `confirmed_by IS NOT NULL` — 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (как сейчас). Если все
`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ (`lock_versions_for_entity` по всем статусам, где
есть привязки этих документов; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая
`CONFIRMED` документы автоподтверждены — дальше блокируются версии ЮЛ «в работе» (`lock_versions_for_entity` берёт только `IN_WORK_STATUSES`; версии вне
«в работе» читаются без блокировки: удаление из них запрещено, возврата в «в работе» нет; порядок «… → документы → advisory ГК → версии» соблюдён) и проверяется, что каждая
версия с привязкой в `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` с
текстом «документ уже участвует в версии на согласовании». Затем привязки снимаются (`consolidation_document`), файл
текстом «документ привязан к версии консолидации вне корректировки данных». Затем привязки снимаются (`consolidation_document`), файл
удаляется как сейчас; `FILE_DELETE.after_value = {unbound_version_ids: [...]}` (по возрастанию; пусто — пустой список),
`before_value` — карточка файла, как сейчас. Статус версии не меняется (свод — чистая функция, пересчитается).
`FILE_SUPERSEDES_OTHERS` — без изменений.
@ -113,3 +113,6 @@
| 9 | 2026-10-08 | Тесты автоподтверждения, маппинга и удаления берут `uploaded_by` у реального пользователя (FK `app_user`), а не случайный uuid (ошибка плана) | случайный uuid не проходит FK | нет |
| 10 | 2026-10-08 | В `tests/test_api_files_delete.py` тесты, имитирующие подтверждение пользователем (`test_confirmed_document_blocks_delete`, `test_delete_rereads_state_after_locks`), получили `confirmed_by` | иначе по правилу удаления (D-4) документ считается автоподтверждённым и удаление проходит | нет |
| 11 | 2026-10-08 | Импорт `auto_confirm` в `result_mapping._map_candidate` — локальный | цикл `documents` → `result_mapping` → `auto_confirm` → `statements` → `documents` | техдолг: вынести `form_confirmable`/`FORM_TYPES` в модуль без обратной зависимости |
| 12 | 2026-10-08 | `make check-full` запускается один раз в конце, после финального ревью и fix-волны (а не внутри Task 6 плана) | не гонять полную проверку дважды | если check-full что-то найдёт — правка после ревью |
| 13 | 2026-10-08 | M-3 (гонка в `bound_versions` при будущем маршруте «вернуть на доработку» Среза 7) и вынос `form_confirmable` из цикла импортов оставлены техдолгом (follow-up) | сейчас возврата версии в «в работе» нет, цикл импортов обойдён локальным импортом | при добавлении возврата на доработку надо отвечать 409 для версий, не попавших в множество, заблокированное `lock_versions_for_entity` |
| 14 | 2026-10-08 | Отменённая/отклонённая/архивная версия с привязкой автоподтверждённого документа блокирует удаление файла (по спеке «вне INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION»), привязки при отмене версии не снимаются | буквальное чтение спеки | файл из отменённой версии удалить нельзя — вопрос владельцу |

View File

@ -49,7 +49,9 @@ DBML и контракты обмена не меняются.
- Код: `services/result_mapping.py` (`_map_candidate`), `services/consolidation_documents.py`
(`on_document_confirmed` с внешним `changed_by`/`comment`, новый `auto_confirm`), `services/documents.py` (общая
проверка условий формы), `services/primary_files.py` (`delete`), `repositories/files.py` (снятие привязок).
- Спеки: `osv-result-mapping`, `documents-api`, `primary-files`, `audit-trail`.
проверка условий формы), `services/primary_files.py` (`delete`; привязки снимает существующий
`consolidation_documents.unbind_documents`).
- Спеки (дельты): `osv-result-mapping`, `primary-files`, `audit-trail`. Ручной путь `POST /documents/{id}/confirm` не
меняется, поэтому дельты `documents-api` нет.
- Поведение для фронта: чистые документы приходят сразу `CONFIRMED` («Загружен»); `confirmed_by = null` — признак
автоподтверждения.