Срез 2c: загрузка файлов ОСВ, цикл маппинга результата в document/osv_entry, API документов, DBML v5.15 #15

Merged
aep merged 35 commits from feat/14-slice-2c-osv-ingest into main 2026-10-06 00:56:15 +03:00
5 changed files with 21 additions and 12 deletions
Showing only changes of commit d14295b6d0 - Show all commits

View File

@ -73,9 +73,9 @@
`pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции →
автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `domain/osv_mapping.py` (чистые функции:
`normalize_account_code`, `build_entries`, `counterparty_check`, `document_status`), `repositories/counterparties.py`
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при `NATS_ENABLED`):
публикатор outbox, вахтёр сроков и цикл маппинга (`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не
читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
(`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при
`NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга
(`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер,
`confirm`, `delete`) — иначе взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`,
`FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется
(OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится.

View File

@ -459,8 +459,9 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
## Цикл маппинга
Фоновая задача lifespan (`services/result_mapping.py:run_mapping_loop`, включается вместе с `NATS_ENABLED`) отделена от
inbox: inbox только фиксирует результат и регистрирует файл результата в `s3_object`, **не читает его**. Цикл с периодом
Фоновая задача lifespan (`services/result_mapping.py:run_mapping_loop`, включается при `NATS_ENABLED` и подключённом
S3 (storage); без S3 — предупреждение в логе, цикл не запускается) отделена от inbox: inbox только фиксирует результат
и регистрирует файл результата в `s3_object`, **не читает его**. Цикл с периодом
`MAPPING_POLL_SECONDS` берёт одно задание `done`/`needs_check`, чей файл `PROCESSING` и для которого оно текущее
(`FOR UPDATE SKIP LOCKED`), затем:
@ -515,11 +516,11 @@ inbox: inbox только фиксирует результат и регист
заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с `CONFIRMED` документом (решение
владельца 05.10.2026); системное событие `FILE_REPLACE` (`user_id` пуст) фиксирует перечень документов и `confirmed=true`.
Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний
уходит в `SUPERSEDED` целиком (OQ — см. `07-open-questions.md`). Автозамены одного ЮЛ идут под advisory-блокировкой.
уходит в `SUPERSEDED` целиком (OQ-115). Автозамены одного ЮЛ идут под advisory-блокировкой.
**Удаление.** `DELETE /files/{id}` удаляет строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
(`uploads`, `results`) `expires_at = now()` (физически объект не удаляется — ретеншн OQ-22) и пишет `FILE_DELETE`
с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`)
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`)
и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы».
@ -527,7 +528,12 @@ inbox: inbox только фиксирует результат и регист
- Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`;
`audit_event` пишут только загрузка, автозамена, удаление, подтверждение.
- Объекты S3 при удалении не стираются (OQ-22): растёт бакет; e2e оставляет ~2 объекта за прогон.
- Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` и число файлов в нём приложением не ограничены
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
- У `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)` и `stored_file(current_recognition_job_id)` нет
индексов: `DELETE /files/{id}` на большой таблице строк ОСВ медленный (seq scan на проверке self-FK) — OQ-117
(нужны DBML v5.16 и миграция 0004).
- Маппер Среза 2c разбирает только листы ОСВ (`osvDocument`); ББ/ОФР и результаты `scan-gateway` — следующие срезы,
прочие документы результата (BALANCE/INCOME/OTHER) игнорируются без предупреждения.
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
@ -633,7 +639,7 @@ email без учёта регистра проверяет только сер
| `OPENBAO__SMB__SMB_PATH` | пусто | путь SMB (в Срезе 0 не используется) |
| `OPENBAO__SMB__SMB_LOGIN` | пусто | логин SMB |
| `OPENBAO__SMB__SMB_PASSWORD` | пусто | пароль SMB |
| `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`: фоновые outbox-публикатор и вахтёр сроков ответа (период 30 с, `processing`/`published` с прошедшим `reply_deadline` → `timeout`); без него фоновых задач нет, `/healthcheck` показывает `nats=off` |
| `NATS_ENABLED` | `false` | подключение к NATS и обмен через `recognition_job`: фоновые outbox-публикатор, вахтёр сроков ответа (период 30 с, `processing`/`published` с прошедшим `reply_deadline` → `timeout`) и цикл маппинга результатов (при подключённом S3, `MAPPING_POLL_SECONDS`); без него фоновых задач нет, `/healthcheck` показывает `nats=off` |
| `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox (`queued` → публикация → `published`) |
| `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации |
| `OUTBOX_MAX_REPUBLISH` | `3` | число повторных публикаций до перевода в `dlq` |

View File

@ -58,7 +58,8 @@ OUTBOX_MAX_REPUBLISH=3
# Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413
IMPORT_MAX_BYTES=20971520
# Загрузка первичных файлов (ББ/ОФР/ОСВ): предельный размер файла в байтах (50 МБ), больше — ответ 413
# Загрузка первичных файлов (ББ/ОФР/ОСВ): предельный размер файла в байтах (50 МБ); больший файл отклоняется в
# rejected[] с кодом FILE_TOO_LARGE — ответ 201, если принят хотя бы один файл запроса, иначе 422
UPLOAD_MAX_BYTES=52428800
# Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки
MAPPING_POLL_SECONDS=5

View File

@ -208,3 +208,5 @@ rows без TOTAL, у каждого не-корня есть родитель,
| 48 | 2026-10-05 | Task 10: удаление блокирует ВСЕ задания файла (`ORDER BY id`); `expires_at` ставится безусловно; удаление Core `DELETE` с очисткой `osv_entry` одним оператором; 409 `FILE_HAS_CONFIRMED_DOCUMENTS` проверяется раньше `FILE_SUPERSEDES_OTHERS`; маршрут DELETE лежит в `endpoints/files/read.py`; `financial_statement_meta`, `consolidation_document`, `ex_snapshot_item` (FK на `document`) не чистятся — в 2c их строк нет, к Срезам 3/4 нужно расширить удаление или запрет (OQ-110) | порядок «задание → файл» для всех попыток; self-FK `parent_entry_id` проверяется в конце оператора | теоретический deadlock; IntegrityError (500) в Срезе 3 без очистки |
| 49 | 2026-10-06 | Task 12: формулировки `tasks.md` 1.2 (`make_entries_from_result` — Task 9), 2.1 (`upload_one`), 3.1 (`build_entries(doc, chart)` без resolve, `resolve(inn, name)`) приведены к коду; в дельту `documents-api` добавлены `job.mapping_error` и 409 `DOCUMENT_NOT_CONFIRMABLE` (R-20, №33) с двумя сценариями | спека — источник истины; код и рулинги ушли от первоначального текста tasks.md | правка текста |
| 50 | 2026-10-06 | Task 12: статус документа `ERROR` описан в README как зарезервированный (ядро Среза 2c его не создаёт: файл без ОСВ → файл `ERROR`); «нарастающая по данным Q1 → `NEEDS_CHECK`» (R-17, №30) вынесена в OQ как вопрос владельцу | в Срезе 2c нет сценария, где документ получает `ERROR`; решение R-17 меняет поведение для всех нарастающих Q2–Q4 | текст README/OQ |
| 51 | 2026-10-06 | R-24 (final review I-1): индексы `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)`, `stored_file(current_recognition_job_id)` в этой ветке НЕ добавляются — нужны DBML v5.16 и миграция 0004 (источник истины — DBML); заведено как OQ-117 и follow-up issue с блокером «до прода / до подсреза 2d (UI удаления)». РЕШЕНИЕ ЧЕЛОВЕКА | поля и индексы вне DBML запрещены; правка модели — отдельный change с версией DBML | `DELETE /files/{id}` деградирует на большой таблице: замер 8,6 с на документ в 1000 строк при 200 тыс. строк (seq scan на проверке self-FK), при ~3 млн строк ≈ 2 мин → HTTP-таймаут и долгие блокировки |
| 52 | 2026-10-06 | R-25 (final review fix wave: M-1, M-3, T7.2): `metrics.mapping_error` — короткая категория вместо текста исключения («результат не соответствует схеме recognition-result: ошибок N, первая: <loc> <type>»; «маппинг не удался: ошибка БД/S3 (<ExcType>), попыток N»), полный текст — только в логе; `DELETE` ставит `expires_at` только объектам папок своего файла (`uploads`+`uploads/{file_id}/`, `results`+`results/{file_id}/`), чужой объект по ссылке задания не трогается и ошибкой не считается (уточняет №36, №48 «безусловно»); черновики `osv_entry` перед вставкой пачками устойчиво сортируются по `level` (родитель раньше потомка на границе пачки); фабрика `make_stored_file` кладёт загрузку в `uploads/{file_id}/…` | `mapping_error` виден пользователю с `DOCUMENT_VIEW` — тексты pydantic/SQLAlchemy раскрывают входные данные и SQL; inbox переиспользует `s3_object` при конфликте `(bucket, key)`; контракт не гарантирует порядок строк | диагностика по карточке беднее (детали — в логе); объект с нестандартным ключом не получит `expires_at` (растёт бакет, OQ-111) |

View File

@ -32,7 +32,7 @@
## 3. Маппинг
- [x] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc,
chart)` без resolve (R-3: `EntryDraft.counterparty_id` проставляет цикл маппинга; пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`;
chart)` без resolve (R-3: `EntryDraft.counterparty_id` проставляет цикл маппинга; пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner_inn, owner_names_normalized)`, `document_status(...)`;
`repositories/counterparties.py:CounterpartyIndex` + `resolve(inn, name) -> uuid | None` (ИНН → имя → алиас, >1 → None).
Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json`
и golden `ОСВ_66 за 2024`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа.