From d14295b6d05286aadb3ca7e8c828f33c1c67b1c7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:38:02 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=202c,=20OQ-117,=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=E2=84=9651=E2=80=9352,=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20README/CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env.example: UPLOAD_MAX_BYTES — больший файл уходит в rejected[] с кодом FILE_TOO_LARGE, ответ 201/422, а не 413 (M-2). - README «Ограничения»: размер multipart-запроса и число файлов приложением не ограничены — лимит ingress платформы (M-4); индексы osv_entry — OQ-117 (I-1); S3 — OQ-22 и OQ-111 (T12.3); автозамена частью документов — OQ-115 (T12.2); удаление ставит expires_at только объектам папок файла (M-3). - README/CLAUDE.md: цикл маппинга запускается при NATS_ENABLED и подключённом S3; строка NATS_ENABLED в таблице настроек перечисляет цикл маппинга (T12.4). - tasks.md 3.1: сигнатура counterparty_check(header, owner_inn, owner_names_normalized) (T12.5). - design.md: №51 (R-24, I-1 — решение человека, OQ-117), №52 (R-25, fix wave M-1/M-3/T7.2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 6 +++--- README.md | 20 ++++++++++++------- api/.env.example | 3 ++- .../changes/slice-2c-osv-ingest/design.md | 2 ++ openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 746f9ef..8bc8261 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` не хранится. diff --git a/README.md b/README.md index f0df7f4..8adfb19 100644 --- a/README.md +++ b/README.md @@ -459,8 +459,9 @@ curl -s "localhost:8000/api/v1/files/" -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` | diff --git a/api/.env.example b/api/.env.example index cae2f29..edcb32c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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 diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index a23dc56..a7d1f64 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -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, первая: »; «маппинг не удался: ошибка БД/S3 (), попыток 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) | diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 68d8330..a2e7125 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -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`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа.