From 239d5bc27026cc755d19c2c05f58c90414cee94d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:08:56 +0400 Subject: [PATCH 01/35] =?UTF-8?q?OpenSpec:=20change=20slice-2c-osv-ingest?= =?UTF-8?q?=20(=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2,=20=D1=86=D0=B8=D0=BA=D0=BB=20?= =?UTF-8?q?=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, design (D-1…D-13), спеки primary-files, osv-result-mapping, documents-api (новые), recognition-job-exchange, data-model (MODIFIED), audit-trail (ADDED), tasks (13 задач). Issue: RSHB/arm-core#14 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../slice-2c-osv-ingest/.openspec.yaml | 2 + .../changes/slice-2c-osv-ingest/design.md | 173 ++++++++++++++++++ .../changes/slice-2c-osv-ingest/proposal.md | 77 ++++++++ .../specs/audit-trail/spec.md | 21 +++ .../specs/data-model/spec.md | 66 +++++++ .../specs/documents-api/spec.md | 37 ++++ .../specs/osv-result-mapping/spec.md | 95 ++++++++++ .../specs/primary-files/spec.md | 91 +++++++++ .../specs/recognition-job-exchange/spec.md | 54 ++++++ openspec/changes/slice-2c-osv-ingest/tasks.md | 68 +++++++ 10 files changed, 684 insertions(+) create mode 100644 openspec/changes/slice-2c-osv-ingest/.openspec.yaml create mode 100644 openspec/changes/slice-2c-osv-ingest/design.md create mode 100644 openspec/changes/slice-2c-osv-ingest/proposal.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/audit-trail/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/primary-files/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/specs/recognition-job-exchange/spec.md create mode 100644 openspec/changes/slice-2c-osv-ingest/tasks.md diff --git a/openspec/changes/slice-2c-osv-ingest/.openspec.yaml b/openspec/changes/slice-2c-osv-ingest/.openspec.yaml new file mode 100644 index 0000000..e3966d7 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md new file mode 100644 index 0000000..4ca15f7 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -0,0 +1,173 @@ +# Design: Срез 2c — загрузка ОСВ, цикл маппинга, документы + +## Context + +Обследование кода 05.10.2026: загрузки нет; outbox (`publish_pending`) берёт задания `queued` через inner join +`stored_file`/`s3_object`; inbox (`handle_message`) в одной короткой транзакции под `FOR UPDATE` фиксирует статус +задания, регистрирует `s3_object` результата и вызывает `ResultMapper` (`NoopMapper`); файл результата из S3 не +читается (`S3Storage.get_object` отдаёт сырой response); `CommittedJobs.cleanup()` не знает `document`/`osv_entry`; +`FakeStorage` умеет только `put_object`. Контракт v1.1 в `api/contracts/` (PR #13). DBML v5.14: `osv_data_type` без +`NU`, `document` уникален по `(stored_file_id, document_type, osv_account_code)`, `stored_file.content_hash` без UNIQUE. +Макет: загрузка из drawer организации, тип и период не выбираются (`40`), удаление — иконка в строке (`39`). +Решения владельца 05.10.2026: OQ-01 (все строки, без «Итого»), OQ-02 (квартальные, сверять по данным), OQ-05 +(`RECOGNIZED` нужен), OQ-45 (`NU` хранить), автозамена при совпадении счёта и периода. + +## Goals / Non-Goals + +**Goals:** сквозной путь файл → `osv_entry` без участия человека; inbox остаётся быстрым; повторный запуск маппинга +безопасен; все статусы файла и документа объяснимы по карточке файла. **Non-Goals:** см. proposal. + +## Decisions + +### D-1. Маппинг — отдельный цикл, признак «ещё не смапплено» — статус файла +`run_mapping_loop(session_factory, storage, settings)` в lifespan рядом с публикатором и вахтёром. Кандидат: +`recognition_job.status IN (done, needs_check)` ⋈ `stored_file` (`status = PROCESSING`, `current_recognition_job_id = +job.id`), `ORDER BY finished_at, id LIMIT 1 FOR UPDATE OF job SKIP LOCKED`. Сначала короткая транзакция «взять +кандидата и прочитать ключ результата», затем чтение S3 **без** транзакции, затем одна транзакция маппинга. Новых +колонок нет (DBML не меняется ради этого). *Альтернатива* — маппинг в inbox: блокирует ack на время вставки тысячи +строк (OQ-69). *Цена ошибки:* вернуть вызов маппера в inbox — одна строка. + +### D-2. Повторы и ошибки маппинга +Счётчик `recognition_job.metrics.mapping_attempts` (jsonb, как `republish_count`). Исключение маппинга → rollback, +`mapping_attempts + 1`, лог; ≥ `MAPPING_MAX_ATTEMPTS` (3) → `stored_file.status = ERROR`, `metrics.mapping_error`. +Ошибки, которые повторять бессмысленно (ключ результата вне `results/{file_id}/{job_id}/`, объект не найден, JSON не +проходит схему v1.1, в результате нет `osvDocument`), → сразу `ERROR` с причиной. Файл в `ERROR` показывается с +`error_code`/`error_message` задания или `metrics.mapping_error`. + +### D-3. Транзакционность и идемпотентность +Маппинг одного файла — одна транзакция: `document`×N, `osv_entry`×M (пакетная вставка `insert().values([...])`), +`recognition_job.group_schema`, `stored_file.status = PROCESSED`, автозамена, аудит `FILE_REPLACE`. Перед вставкой — +`DELETE osv_entry/document WHERE stored_file_id = file` (защита от половинчатого состояния после сбоя; строки ещё не +`CONFIRMED`, потому что файл был `PROCESSING`). `document` по индексу `(stored_file_id, document_type, +osv_account_code)` — второй лист с тем же счётом в документ не превращается (D-7). + +### D-4. Загрузка +Один эндпоинт на несколько файлов; каждый файл — своя транзакция (частичный успех: ответ `{"accepted": [...], +"rejected": [{"name", "code", "message"}]}`). Шаги на файл: `safe_filename`; размер ≤ `UPLOAD_MAX_BYTES` (50 МиБ, как +`PARSE_MAX_BYTES` парсера) иначе `413`/код `FILE_TOO_LARGE`; тип по расширению, сверка с сигнатурой (zip/BIFF8/PDF/PNG/ +JPEG), несовпадение → `UNSUPPORTED_FILE`; sha256; дедуп: `stored_file` того же ЮЛ с тем же `content_hash` и `status != +SUPERSEDED` → `DUPLICATE_FILE` с `file_id` существующего; `job_id = uuid4()` заранее, ключ +`uploads/{file_id}/{job_id}/{имя}`; `S3Object` (flush) → `put_object` → `StoredFile(UPLOADED, version_no=1)` → +`RecognitionJob(queued, pipeline по типу, request_subject, schema_version=CONTRACT_VERSION, max_attempts 3/1)` → +`stored_file.current_recognition_job_id`. Без `storage` (dev без S3) — `503` `STORAGE_UNAVAILABLE`. Аудит `FILE_UPLOAD` +на каждый принятый файл. + +### D-5. Статусы файла (§9.2) и кто их ставит +| Событие | Кто | `stored_file.status` | +|---|---|---| +| загрузка | эндпоинт | `UPLOADED` | +| ack сервиса (`processing`) | inbox | `PROCESSING` | +| результат `error`; вахтёр `timeout`; публикатор `dlq` | inbox / вахтёр / публикатор | `ERROR` | +| результат `done`/`needs_check` | inbox | без изменений (`PROCESSING`), подхватит цикл | +| маппинг успешен | цикл | `PROCESSED` | +| маппинг невозможен / исчерпаны повторы | цикл | `ERROR` | +| автозамена | цикл (после маппинга нового) | `SUPERSEDED` у прежних | +Переходы — только через `stored_file.current_recognition_job_id = job.id` (чужое/старое задание файл не трогает). + +### D-6. Маппинг строк (§1.3) +`domain/osv_mapping.py` (чистые функции): `normalize_account_code("60.1") = "60.01"` (числовые сегменты → 2 цифры, +третий сегмент как есть `76.07.1`, буквенные сегменты без изменений `76.АВ`; первый сегмент нечисловой — `account_root = +None`, `ASSUMPTION(OQ-103)`); `account_root` = первый сегмент (`001` для забалансовых); `build_entries(doc: OsvDocument, +chart: Mapping[str, uuid], resolve: Callable[[row], uuid | None]) -> list[EntryDraft]`: пропуск `TOTAL`; `account_code` += `account_code_raw` строки (у не-счётов — унаследованный, парсер его уже проставляет); `account_id = chart.get( +normalize(account_code))`; `parent_entry_id` через карту `row_number → id` (id генерируются заранее `uuid4`); +`data_type` как есть (`NU` допустим после v5.15); суммы — `Decimal` из контракта без изменений; `counterparty_*` как +пришли; `is_modified = false`. + +### D-7. Документ, предупреждения, статус (§9.3) +На `osvDocument`: `osv_account_code = normalize(header.account_code_raw)`, `period_*`, `is_cumulative` по шапке, затем +проверка по данным (D-9), `locator`. Повтор счёта в том же файле → документ не создаётся, предупреждение +`sheet=: счёт <код> уже есть в листе `, первый документ → `NEEDS_CHECK`. Статус: +`RECOGNIZED`, если `job.status = done` **и** `checks` без нарушений **и** `counterparty_check != MISMATCH` **и** нет +предупреждений маппинга; иначе `NEEDS_CHECK`. Предупреждения маппинга пишутся в `recognition_job.metrics.mapping_warnings` +(jsonb, список строк) — отдельной колонки в DBML нет, а карточке файла они нужны. + +### D-8. Резолв контрагента (§3 п. 4) и проверка шапки +`repositories/counterparties.py:CounterpartyIndex` — одним запросом на файл: `legal_entity(inn, name)` + все алиасы → +словари `inn → id`, `normalize_alias(name) → {id…}` (имя ЮЛ нормализуется той же функцией, что алиас), `alias → {id…}`. +Резолв строки (только `COUNTERPARTY`, `data_type ∈ {BU, SUM}`): `counterparty_inn` → `inn`; иначе точное имя (после +`strip`) → по точному `legal_entity.name`; иначе `normalize_alias(counterparty_name)` → по нормализованным именам и +алиасам; на любом шаге > 1 кандидата → `None`. Без policy (контрагент может быть ЮЛ другого филиала). +`counterparty_check`: `header.organization_inn` есть → `MATCH` если равен `inn` владельца, иначе `MISMATCH`; ИНН нет → +`normalize_alias(header.organization_name)` ∈ нормализованные имя/алиасы владельца → `MATCH`, иначе `MISMATCH`; пустая +шапка → `UNKNOWN`. `period_check = UNKNOWN` (нет версии консолидации; Срез 4 пересчитает). + +### D-9. `is_cumulative` по данным (OQ-02, `ASSUMPTION`) +Если шапка дала `Q2|Q3|Q4` с `is_cumulative = false`: ищется документ того же ЮЛ, того же `osv_account_code`, +`period_year`, `period_quarter = Q1`, файл не `SUPERSEDED`; если у корневой строки `opening_debit/credit` совпадают с +Q1 (±0.01) — `is_cumulative = true` и предупреждение `период …: нарастающая по данным Q1`. Нет Q1 — остаётся по тексту. + +### D-10. Автозамена и удаление +После `PROCESSED`: для каждого нового документа — `stored_file` того же ЮЛ, `status != SUPERSEDED`, `id != new`, с +`document` того же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`, +`superseded_by_file_id = new`, `updated_at`; у нового `version_no = max(version_no заменённых) + 1`; аудит +`FILE_REPLACE` (`object_id` старого, `after_value` новый, `description` с перечнем заменённых документов и пометкой +`confirmed=true`, если был `CONFIRMED`; `user_id = NULL` — системное действие, инициатор загрузки в `description`). +`DELETE /files/{id}` (`DOCUMENT_EDIT`, policy): есть `CONFIRMED` документ → `409 FILE_HAS_CONFIRMED_DOCUMENTS`; +иначе `osv_entry`, `document`, `recognition_job` (все попытки), `stored_file` удаляются, `s3_object.expires_at = now` +(объект физически не удаляется — ретеншн OQ-22), аудит `FILE_DELETE` с `before_value`. Файл `SUPERSEDED` удалить можно +(он ни на что не влияет). `superseded_by_file_id` у заменённых обнуляется при удалении нового? — нет: удаление файла, который +кого-то заменил, запрещено тем же 409 с кодом `FILE_SUPERSEDES_OTHERS` (цепочка версий не рвётся). + +### D-11. API чтения +`GET /legal-entities/{id}/files?include_superseded=false` → `Page[FileOut]` (`FileOut`: файл, `job{status, error_code, +error_message, metrics.warnings, mapping_warnings}`, `documents[DocumentBrief]`); `GET /files/{id}` → `FileOut`; +`GET /documents/{id}` → `DocumentOut` (+ `checks` из результата? — результат в S3; документ хранит только статус и +проверки, поэтому `checks` парсера отдаются из `recognition_job.metrics` — не хранятся; в `DocumentOut` — `period_check`, +`counterparty_check`, `status`, `group_schema` по `locator` из `job.group_schema`); `GET /documents/{id}/entries?limit≤2000` +→ `Page[EntryOut]` в порядке `row_number` (дерево восстанавливается по `parent_entry_id`); `POST /documents/{id}/confirm` +→ `CONFIRMED`, `confirmed_by/at`, аудит `DOCUMENT_CONFIRM`; из `CONFIRMED` повторно — 409. Видимость — через ЮЛ-владельца +(`legal_entities_visible`), чужое → 404. + +### D-12. S3 +`S3Storage.get_bytes(key) -> bytes` (чтение `response.content.read()` + закрытие), `put_object` как есть; `FakeStorage` +получает `get_bytes` и `objects`. Чтение результата — по `job.result_s3_object.key` (бакет сверяется с `storage.bucket`). + +### D-13. Тесты +`tests/factories.py`: `make_stored_file`, `make_job`, `make_document`; `CommittedJobs.cleanup()` расширен на +`osv_entry`/`document`. Unit: `domain/osv_mapping.py`, резолв на словарях. Integration: загрузка (типы, лимит, дубль, +частичный успех, policy), статусы по ack/результату/таймауту, цикл маппинга на результате из golden-снимка +`rb/2024-FY/ОСВ_66 за 2024.xls` (`FakeStorage` с JSON), повторы и `ERROR`, автозамена с `CONFIRMED`, удаление, API. +`fixtures`: маппинг всех 82 golden-снимков (`../fixtures/golden/expected_osv_parse/**`) в БД — инварианты: число строк = +rows без TOTAL, у каждого не-корня есть родитель, `account_id` заполнен у всех счетов из плана, суммы совпадают с +снимком. Сквозной прогон через compose (профиль `services` с реальным `tabular-parser`) — отдельный `integration`-тест, +помеченный `e2e`, запускается в `make check-full` при поднятом профиле (иначе skip с причиной). + +## Risks / Trade-offs + +- [Два цикла и inbox пишут в `stored_file`] → только по `current_recognition_job_id`; тесты гонок (вахтёр vs цикл). +- [Удаление и повторная вставка при повторе маппинга] → файл не `CONFIRMED` в `PROCESSING`, данных пользователя нет. +- [Резолв по имени даёт ложные совпадения у физлиц/ИП] → только точное имя или алиас, никакой нечёткости; ручное + сопоставление — Срез 4. +- [Автозамена трогает `CONFIRMED`] → решение владельца; предупреждение в аудите и в ответе загрузки нет (асинхронно) — + видно в карточке файла. +- [e2e-тест зависит от профиля `services`] → skip с причиной, не падение. + +## Migration Plan + +`0003_dbml_v5_15`: `ALTER TYPE osv_data_type ADD VALUE 'NU' AFTER 'BU'` (downgrade — пересоздание типа, как в 0002); +`UPDATE app_user SET email = lower(email)` (при дублях по регистру миграция падает — осознанно; downgrade данные не +возвращает). `HEAD` в тестах миграций → `0003_dbml_v5_15`. + +## Open Questions + +Не блокируют: OQ-22 (ретеншн — `expires_at` при удалении), OQ-55 (аудит обмена — нет), OQ-73 (replay — нет), OQ-100/103 +(допущения), OQ-109 (размер объекта до скачивания — у ядра лимит на загрузке, парсер — как есть). + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-05 | D-1: цикл маппинга отделён от inbox, признак — `stored_file.status = PROCESSING` | OQ-69; без новых колонок | вернуть в inbox | +| 2 | 2026-10-05 | D-2: 3 попытки маппинга, счётчик в `metrics`; неповторяемые ошибки → `ERROR` сразу | как `republish_count` | числа в Settings | +| 3 | 2026-10-05 | D-3: перед вставкой удаляются строки/документы файла (идемпотентность) | файл в `PROCESSING` не содержит правок | нет | +| 4 | 2026-10-05 | D-4: частичный успех, дедуп по sha256 в пределах ЮЛ, `job_id` заранее для ключа S3, лимит 50 МиБ | макет: несколько файлов; схема ключей 03-architecture | формат ответа | +| 5 | 2026-10-05 | D-5: `ERROR` файла ставят inbox/вахтёр/публикатор при финальных ошибках задания | иначе файл вечно `PROCESSING` | нет | +| 6 | 2026-10-05 | D-6: нормализация кода — числовые сегменты до 2 цифр, буквенные как есть; `account_root = None` при нечисловом первом сегменте | §1.3; OQ-103 | правка функции | +| 7 | 2026-10-05 | D-7: предупреждения маппинга — в `metrics.mapping_warnings`; повтор счёта в файле → первый лист + `NEEDS_CHECK` | колонки нет в DBML; уникальность документа | DBML v5.16 с колонкой | +| 8 | 2026-10-05 | D-8: резолв без policy; имя ЮЛ нормализуется как алиас; `period_check = UNKNOWN` до Среза 4 | контрагент может быть чужого филиала; версии нет | пересчёт в Срезе 4 | +| 9 | 2026-10-05 | D-9: `is_cumulative` по данным — только сравнение с Q1 того же года | OQ-02 | расширить правило | +| 10 | 2026-10-05 | D-10: удаление запрещено при `CONFIRMED` и если файл заменил другие; S3-объект получает `expires_at` | ANL-020, OQ-22 | ослабить проверку | +| 11 | 2026-10-05 | D-11: `checks` парсера в БД не хранятся, карточка документа отдаёт статус и проверки ядра | DBML | колонка/чтение из S3 | +| 12 | 2026-10-05 | D-13: e2e через профиль `services` помечен `e2e` и пропускается без профиля | нет CI | — | +| 13 | 2026-10-05 | v5.15: email в нижнем регистре вместо expression-индекса `lower(email)` | сверка ORM↔DBML не умеет expression-индексы | индекс в v5.16 | diff --git a/openspec/changes/slice-2c-osv-ingest/proposal.md b/openspec/changes/slice-2c-osv-ingest/proposal.md new file mode 100644 index 0000000..9591ea3 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/proposal.md @@ -0,0 +1,77 @@ +# Proposal: Срез 2c — загрузка файлов ОСВ, очередь распознавания, маппинг результата в document/osv_entry + +**Срез:** 2, подсрез 2c (`../docs/context/09-delivery-plan.md`; порядок по решению владельца 05.10.2026: 2a → 2c → 2b → 2d). +**Репозиторий:** arm-core. **Issue:** RSHB/arm-core#14. **Родительский change:** `../docs/openspec/changes/slice-2c-osv-ingest` +(DBML v5.15, правила по факту, OQ; RSHB/arm-ka-docs#16). Фикстуры не меняются (golden-снимки парсера из Среза 2a). + +## Why + +После Среза 2a парсер разбирает ОСВ, но результат некуда положить: в ядре нет загрузки файлов (`stored_file` нигде не +создаётся), inbox фиксирует конверт, а маппер — `NoopMapper`. Без 2c нет сквозного пути «файл → строки ОСВ в БД», на +котором стоят чек-лист (Срез 3), сводная ОСВ и ВГО (Срезы 4–5), и тестировщику нечего проверять на реальных данных. + +## What Changes + +- **DBML v5.15**: `osv_data_type` + `NU` (OQ-45); `app_user.email` хранится в нижнем регистре (OQ-59, нормализация при + записи, UNIQUE прежний); миграция `0003_dbml_v5_15` (enum + `UPDATE app_user SET email = lower(email)`); `DBML_VERSION`, + `Makefile`, `dbml_parity`, тесты миграций — на v5.15. Таблиц 43, enum'ов 27. +- **Загрузка файлов** `POST /api/v1/legal-entities/{id}/files` (multipart, несколько файлов, `DOCUMENT_EDIT`, policy + видимости ЮЛ): `s3_object` (`uploads/{file_id}/{job_id}/{имя}`, sha256) + `stored_file(UPLOADED)` + + `recognition_job(queued)` + `current_recognition_job_id` одной транзакцией; тип по расширению и сигнатуре + (xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`); дедуп по sha256 в пределах ЮЛ (409); лимит `UPLOAD_MAX_BYTES` + 50 МиБ; частичный успех по файлам; аудит `FILE_UPLOAD`. +- **Статусы файла в обмене**: ack → `PROCESSING`; `error`/`timeout`/`dlq` → `ERROR` с причиной (OQ-67, inbox). +- **Цикл маппинга** (`services/result_mapping.py`, фоновая задача lifespan, период `MAPPING_POLL_SECONDS`): задание + `done|needs_check` с файлом `PROCESSING` и `current_recognition_job_id = job` → `FOR UPDATE SKIP LOCKED` → проверка ключа + (OQ-70) → чтение результата из S3 вне транзакции → валидация v1.1 → одна транзакция: `document` + `osv_entry` + + `group_schema` + статус файла `PROCESSED`; ошибка чтения/валидации → `ERROR`; исключение → повтор до 3 раз + (`metrics.mapping_attempts`), затем `ERROR` (OQ-69 — маппинг отделён от inbox). +- **Маппинг** по §1.3/§3/§9.3: все строки кроме `TOTAL` (OQ-01) с `parent_entry_id`; нормализация кода счёта, + `account_root`, `account_id` по плану счетов; резолв контрагента ИНН → имя → алиас, несколько кандидатов → NULL; + `counterparty_check` по шапке; `period_check = UNKNOWN` до Среза 4; `is_cumulative` по данным Q1 (OQ-02); статус + документа `RECOGNIZED` | `NEEDS_CHECK` (OQ-05); второй лист с тем же счётом — предупреждение и `NEEDS_CHECK`. +- **Автозамена** (решение владельца 05.10.2026): после маппинга прежние файлы того же ЮЛ с документом того же типа, счёта + и периода → `SUPERSEDED` (`superseded_by_file_id`, `version_no`), аудит `FILE_REPLACE` (с предупреждением, если + заменён `CONFIRMED`). **Удаление** `DELETE /files/{id}` — пока нет `CONFIRMED` документов; аудит `FILE_DELETE`. +- **API чтения** (`DOCUMENT_VIEW`): список файлов ЮЛ с заданием и документами, карточка файла, документ с проверками и + схемой, строки документа деревом (до 2000 на страницу); **подтверждение** `POST /documents/{id}/confirm` + (`DOCUMENT_EDIT`, `DOCUMENT_CONFIRM`). +- **S3**: `S3Storage.get_bytes(key)`; `FakeStorage.get_bytes` в тестах. +- **Тесты**: unit (нормализация кода, резолв, проверки шапки), integration (загрузка/дедуп/статусы/цикл/автозамена/ + удаление/API), `fixtures` — маппинг всех 82 golden-снимков парсера без NATS и сквозной прогон через compose с + реальным `tabular-parser` на `rb/2024-FY/ОСВ_66 за 2024.xls`. +- **Документы**: README (загрузка, статусы, маппинг, API), CLAUDE.md, спеки. + +## Capabilities + +### New Capabilities +- `primary-files`: загрузка файлов отчётности, дедуп, статусы файла, автозамена, удаление. +- `osv-result-mapping`: цикл маппинга результата распознавания в `document`/`osv_entry`, резолвы, проверки, статусы документа. +- `documents-api`: чтение файлов, документов и строк ОСВ, подтверждение документа. + +### Modified Capabilities +- `recognition-job-exchange`: ack и финальные статусы задания меняют `stored_file.status`; результат `done|needs_check` + не маппится в inbox, а передаётся циклу маппинга (`NoopMapper` и протокол `ResultMapper` удаляются). +- `data-model`: схема равна DBML **v5.15**, цепочка миграций 0001 → 0003. +- `audit-trail`: события файлов и документов (`FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM`). + +## Impact + +- **Таблицы DBML v5.15:** `s3_object`, `stored_file`, `recognition_job` (`group_schema`, `metrics`), `document`, + `osv_entry`, `chart_of_account` (чтение), `legal_entity`, `legal_entity_alias` (чтение), `app_user` (email в + нижнем регистре), `audit_event`. Полей вне DBML нет. +- **Правила:** §1.3 (маппинг строк, нормализация, `account_root`/`account_id`), §1.5 (`is_cumulative` по данным, + `period_check`), §3 п. 4 (резолв контрагента), §9.2–9.4 (статусы). Допущения: `ASSUMPTION(OQ-02)` (по данным — только + сравнение с Q1 того же года), `ASSUMPTION(OQ-55)` (события обмена не аудируются), `ASSUMPTION(OQ-100)` (ИНН из текста + строки не извлекается), `ASSUMPTION(OQ-103)` (буквенный первый сегмент → `account_id = NULL`). +- **Соседи:** `../docs` (DBML v5.15, §, OQ), `tabular-parser` — без изменений; compose — без изменений. +- **Seed:** `users.yaml` dev-email уже в нижнем регистре; `permissions`/матрица не меняются (`DOCUMENT_VIEW`/`DOCUMENT_EDIT` есть). + +## Non-goals + +- UI (панель «Первичные документы», окно загрузки, экран проверки ОСВ) — подсрез 2d. +- Маппинг `statementDocument` (ББ/ОФР), логические проверки §7, правка распознанных значений + (`RECOGNIZED_DATA_EDIT`), чек-лист — Срез 3 / подсрез 2b. +- Ручное сопоставление контрагентов (`OSV_COUNTERPARTY_MAP`), `period_check` по версии консолидации — Срез 4. +- Replay заданий (`dlq → queued`, OQ-73), аудит смены статусов задания (OQ-55), JetStream. +- Ограничение удаления по консолидациям (ANL-020) — Срез 4. diff --git a/openspec/changes/slice-2c-osv-ingest/specs/audit-trail/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/audit-trail/spec.md new file mode 100644 index 0000000..39b0ef4 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/audit-trail/spec.md @@ -0,0 +1,21 @@ +# Spec Delta: audit-trail + +## ADDED 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` | инициатор | + +`FILE_REPLACE` SHALL содержать в `after_value` новый `file_id`, в `description` — перечень заменённых документов и +`confirmed=true`, если среди них был `CONFIRMED`. Смена статусов задания и файла в обмене и в цикле маппинга +`audit_event` не пишет (`ASSUMPTION(OQ-55)`). + +#### Scenario: Загрузка двух файлов +- **WHEN** принято два файла одним запросом +- **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`) diff --git a/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md new file mode 100644 index 0000000..d62d583 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md @@ -0,0 +1,66 @@ +# Spec Delta: data-model + +## RENAMED Requirements +- FROM: `### Requirement: Схема БД равна DBML v5.14` +- TO: `### Requirement: Схема БД равна DBML v5.15` + +## MODIFIED Requirements + +### Requirement: Схема БД равна DBML v5.15 +ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.15.dbml` в схеме `dkb`; +имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения +по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` +`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE +`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`. +`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). +`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`). +`app_user.email` SHALL храниться в нижнем регистре (нормализация при записи seed'ом и администрированием). Поля, +отсутствующие в DBML, MUST NOT существовать. + +#### Scenario: Сверка ORM с DBML +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml` +- **THEN** вывод пуст и код возврата 0 + +#### Scenario: Лишняя колонка в ORM +- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая) +- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1 + +#### Scenario: Расхождение типа или nullability +- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` +- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 + +#### Scenario: ЮЛ одновременно в ГСК и ГСЗ +- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz` +- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс + +#### Scenario: Строка налогового учёта +- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'` +- **THEN** вставка проходит + +### Requirement: Миграции применяются с нуля и откатываются +Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все +enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) +на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type` и приводить +`app_user.email` к нижнему регистру; `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через +пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок. + +#### Scenario: Полный цикл +- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head` +- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы модели (+ служебная `alembic_version`) + и в `pg_type` 27 enum-типов + +#### Scenario: Откат на один шаг +- **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14` +- **THEN** `osv_data_type` не содержит `NU`; регистр `app_user.email` не возвращается (данные) + +#### Scenario: Автогенерация ничего не находит +- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД +- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`5.15`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.16.dbml`, а константа в ORM — `5.15` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц diff --git a/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md new file mode 100644 index 0000000..ccd12fb --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md @@ -0,0 +1,37 @@ +# Spec Delta: documents-api + +## Purpose + +Чтение файлов, документов и строк ОСВ для панели «Первичные документы» (подсрез 2d) и подтверждение документа. + +## ADDED Requirements + +### Requirement: Файлы организации и карточка файла +`GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL +возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file`, текущее задание (`status`, `error_code`, +`error_message`, `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`, +`osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`). +`GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404. + +#### Scenario: Список после обработки +- **WHEN** у ЮЛ два файла: `PROCESSED` с документом и `ERROR` +- **THEN** оба в списке; у первого `documents` непустой, у второго `job.error_code` заполнен + +### Requirement: Документ и строки +`GET /api/v1/documents/{document_id}` (`DOCUMENT_VIEW`, policy по ЮЛ владельца) SHALL отдавать документ, статус, +проверки, `locator`, схему группировок листа из `recognition_job.group_schema` и `file_id`. +`GET /api/v1/documents/{document_id}/entries` SHALL отдавать `Page` строк по возрастанию `row_number` (`limit` ≤ 2000, +по умолчанию 500) с полями `osv_entry`, включая `parent_entry_id`, `account_id`, `counterparty_id`. + +#### Scenario: Строки деревом +- **WHEN** запрошены строки документа ОСВ 66 +- **THEN** первая строка — счёт `66` уровня 0 без родителя, у строк уровня 2 `parent_entry_id` указывает на субсчёт + +### Requirement: Подтверждение документа +`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED` +или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`; +из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`. + +#### Scenario: Подтверждение +- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала +- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK` diff --git a/openspec/changes/slice-2c-osv-ingest/specs/osv-result-mapping/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..11f85cd --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/osv-result-mapping/spec.md @@ -0,0 +1,95 @@ +# Spec Delta: osv-result-mapping + +## Purpose + +Маппинг файла результата распознавания (`recognition-result.schema.json` v1.x, `osvDocument`) в `document` и +`osv_entry` отдельным фоновым циклом (§1.3, §1.5, §3 п. 4, §9.3); резолв счетов и контрагентов; проверки шапки; +статус документа. + +## ADDED Requirements + +### Requirement: Цикл маппинга отделён от inbox +Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done, +needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP +LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с +`results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и +в одной транзакции создать документы и строки, записать `recognition_job.group_schema`, перевести файл в `PROCESSED` +и выполнить автозамену. Inbox MUST NOT читать файл результата и MUST NOT создавать документы. + +#### Scenario: Результат смапплен +- **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING` +- **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document` + (`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`), + `osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]` + +#### Scenario: Ключ результата вне папки задания +- **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json` +- **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется + +#### Scenario: Сбой маппинга с повтором +- **WHEN** вставка строк падает по ошибке БД +- **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR` + с `metrics.mapping_error` + +#### Scenario: Повторный маппинг идемпотентен +- **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова +- **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет + +### Requirement: Документ по листу ОСВ +На каждый `osvDocument` ядро SHALL создать `document` с `document_type = OSV`, `osv_account_code` = нормализованный +код из шапки (`60.1 → 60.01`, `58.03`, буквенные сегменты как есть), `period_year`, `period_quarter`, `is_cumulative` +по шапке, `locator`, `legal_entity_id` владельца файла. Второй лист того же файла с тем же счётом MUST NOT создавать +документ: в `metrics.mapping_warnings` пишется предупреждение, первый документ получает `NEEDS_CHECK`. + +#### Scenario: Два листа одного счёта +- **WHEN** результат содержит два `osvDocument` со счётом `60` +- **THEN** один `document`, статус `NEEDS_CHECK`, предупреждение называет оба `locator` + +### Requirement: Строки ОСВ +Ядро SHALL сохранить все строки `osvDocument.rows` кроме `group_type = TOTAL` (OQ-01) как `osv_entry`: `row_number`, +`level`, `row_value`, `group_type`, `group_name`, `data_type` (включая `NU`), шесть сумм без изменений, +`counterparty_name`/`counterparty_inn` как в результате, `parent_entry_id` по `parent_row_number`, `account_code` +(как в результате), `account_root` (первый сегмент, `None` если он не цифровой — `ASSUMPTION(OQ-103)`), `account_id` +(точное совпадение нормализованного кода с `chart_of_account.code`, иначе NULL), `owner_legal_entity_id`, +`recognition_job_id`, `is_modified = false`. + +#### Scenario: Дерево и счета +- **WHEN** смапплен результат `rb/2024-FY/ОСВ_66 за 2024.xls` +- **THEN** строк `osv_entry` = строк результата − 1 (TOTAL); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_entry_id` + указывает на строку `66.03`, `account_code = "66.03"`, `account_root = "66"`, `account_id` = строка плана счетов `66.03` + +#### Scenario: Все golden-снимки парсера +- **WHEN** в БД маппятся все `../fixtures/golden/expected_osv_parse/**` (82 результата, маркер `fixtures`) +- **THEN** для каждого: число строк = `rows` без TOTAL, у каждой строки уровня > 0 есть родитель, суммы равны снимку, + `account_id` заполнен у всех строк-счетов, код которых есть в плане счетов seed + +### Requirement: Резолв контрагента +Для строк `group_type = COUNTERPARTY` с `data_type ∈ {BU, SUM}` ядро SHALL искать `counterparty_id` по §3 п. 4: по +`counterparty_inn` в `legal_entity.inn`; иначе по точному `counterparty_name` в `legal_entity.name`; иначе по +`normalize_alias(counterparty_name)` среди нормализованных имён ЮЛ и `legal_entity_alias.normalized_alias`; больше +одного кандидата на шаге → `NULL`. Резолв SHALL выполняться по всему справочнику без фильтра видимости. + +#### Scenario: По ИНН, по алиасу, неоднозначно +- **WHEN** в справочнике есть ЮЛ с ИНН `3528052718`, ЮЛ с алиасом `русский бисквит`, и два ЮЛ с одинаковым нормализованным именем `альфа` +- **THEN** строка с ИНН `3528052718` → это ЮЛ; строка «Русский бисквит» без ИНН → ЮЛ по алиасу; строка «ООО Альфа» → `counterparty_id = NULL` + +### 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). + +#### 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 = RECOGNIZED`, `counterparty_check = MATCH`, `period_check = UNKNOWN` diff --git a/openspec/changes/slice-2c-osv-ingest/specs/primary-files/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/primary-files/spec.md new file mode 100644 index 0000000..869b79e --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/primary-files/spec.md @@ -0,0 +1,91 @@ +# Spec Delta: primary-files + +## Purpose + +Загрузка файлов первичной отчётности в ядро: физический файл в S3 и `stored_file`, задание распознавания в outbox, +дедуп, жизненный цикл статуса файла (§9.2), автозамена прежних версий и удаление. + +## ADDED Requirements + +### Requirement: Загрузка файлов организации +`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право +`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции: +определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`, +`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать +`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`, +`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`, +`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`) и `recognition_job` (`status = +queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`; `max_attempts` 3 / 1), установить +`stored_file.current_recognition_job_id`, записать `audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)`. Ответ SHALL +перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`, +`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT +запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`. + +#### Scenario: Реальная ОСВ принята и ушла в outbox +- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала +- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` = + sha256 файла; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`; + публикатор outbox публикует запрос с этим ключом + +#### Scenario: Смесь принятых и отклонённых +- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ +- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE` + +#### Scenario: Расширение не совпадает с содержимым +- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8 +- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE` + +#### Scenario: Чужое ЮЛ +- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала +- **THEN** 404, объектов и записей нет + +### Requirement: Дедуп по содержимому +Файл с тем же sha256, что у существующего `stored_file` того же ЮЛ со статусом не `SUPERSEDED`, SHALL отклоняться с +кодом `DUPLICATE_FILE` и `file_id` существующего. Тот же файл у другого ЮЛ SHALL приниматься. + +#### Scenario: Повторная загрузка того же файла +- **WHEN** тот же xls загружается во второй раз для того же ЮЛ +- **THEN** `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — прежний файл; новых записей нет + +### Requirement: Жизненный цикл статуса файла +`stored_file.status` SHALL меняться только по заданию `current_recognition_job_id`: `UPLOADED` при загрузке; +`PROCESSING` при ack сервиса; `ERROR` при финальной ошибке задания (`error`, `timeout`, `dlq`) и при невозможности +маппинга; `PROCESSED` после успешного маппинга; `SUPERSEDED` при автозамене. Карточка файла SHALL показывать причину +`ERROR` (`error_code`/`error_message` задания или ошибку маппинга). + +#### Scenario: Парсер вернул ошибку +- **WHEN** конверт `error UNSUPPORTED_FORMAT` для задания файла +- **THEN** `stored_file.status = ERROR`, в карточке файла `error_code = UNSUPPORTED_FORMAT` + +#### Scenario: Сервис не ответил +- **WHEN** задание переведено вахтёром в `timeout` +- **THEN** файл — `ERROR` с `error_code = TIMEOUT` + +### Requirement: Автозамена прежних версий +После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть +документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в +`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди +заменённых + 1. Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться. +Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с новым файлом, в `description` — перечень заменённых +документов и признак `confirmed`, если заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит +и для `CONFIRMED`. + +#### Scenario: Новая ОСВ 60 за 1 кв. 2025 +- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом +- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)` + с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый + +### 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 = карточка файла)`. Удаление MUST быть отклонено 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, если у файла есть +документ `CONFIRMED`, и 409 `FILE_SUPERSEDES_OTHERS`, если на файл ссылаются `superseded_by_file_id`. Файл +`SUPERSEDED` удалить можно. + +#### Scenario: Удаление ошибочного файла +- **WHEN** файл в `ERROR` удаляется владельцем +- **THEN** 204; `stored_file`, `recognition_job` отсутствуют; `s3_object.expires_at` заполнен; `audit_event(FILE_DELETE)` + +#### Scenario: Подтверждённый документ +- **WHEN** удаляется файл с документом `CONFIRMED` +- **THEN** 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, ничего не изменилось diff --git a/openspec/changes/slice-2c-osv-ingest/specs/recognition-job-exchange/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/recognition-job-exchange/spec.md new file mode 100644 index 0000000..ef17535 --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/specs/recognition-job-exchange/spec.md @@ -0,0 +1,54 @@ +# Spec Delta: recognition-job-exchange + +## MODIFIED Requirements + +### Requirement: Ack сервиса переводит задание в `processing` +Получив в `recognition.result` конверт со `status = processing` для задания в `published`, ядро SHALL установить +`status = processing`, `started_at`, `service_name`, `service_version` и перевести файл задания в +`stored_file.status = PROCESSING`, если `current_recognition_job_id` файла равен этому заданию. Повторный ack для +задания в `processing` SHALL игнорироваться; ack для задания в другом статусе SHALL логироваться и не менять задание. + +#### Scenario: Сервис подтвердил +- **WHEN** сервис опубликовал конверт `processing` с `job_id` опубликованного задания +- **THEN** задание имеет `status = processing`, `started_at` заполнен, `service_name = tabular-parser`, файл — `PROCESSING` + +### Requirement: Inbox принимает результат идемпотентно +Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по +`recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен +`Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`, +`created_by = `; только при наличии `result_s3`), обновить задание (`status`, `service_name`, +`service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`, +`result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен +заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть +обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL +быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`, +`timeout`, `dlq`), SHALL логироваться и не применяться. + +#### Scenario: Успешный результат +- **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results///result.json}` и + `Nats-Msg-Id = result::1` +- **THEN** задание получает `status = done`, `result_msg_id = result::1`, `result_s3_object_id` указывает на новую + строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга + +#### Scenario: Повторная доставка +- **WHEN** то же сообщение получено второй раз +- **THEN** строка `recognition_job` и `s3_object` не меняются + +#### Scenario: Ошибка сервиса +- **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3` +- **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта, + `result_s3_object_id` пуст; файл — `ERROR` + +#### Scenario: Результат пришёл после таймаута +- **WHEN** задание уже `timeout`, и приходит конверт `done` +- **THEN** задание не меняется, событие залогировано + +### Requirement: Таймаут ожидания результата +Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в +`status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в +`stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Replay +задания (новая попытка, `attempt + 1`) — вне этого среза. + +#### Scenario: Сервис взял, но не ответил +- **WHEN** задание в `processing` и `reply_deadline` прошёл без результата +- **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT` diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md new file mode 100644 index 0000000..11fbbbf --- /dev/null +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -0,0 +1,68 @@ +# Tasks: Срез 2c — arm-core + +Каждая задача ≤ половины дня, начинается с тестов, заканчивается зелёным `make check` и интеграционными тестами своей +области. Допущения в коде — `# ASSUMPTION(OQ-nn)`. DBML v5.15 — `../docs/model/data-model-v5.15.dbml` (ветка +`feat/16-slice-2c-osv-ingest` в `.wt/slice-2c/docs`). + +## 1. Модель и инфраструктура + +- [ ] 1.1 **DBML v5.15, миграция 0003.** `OsvDataType.NU` (после BU), миграция `0003_dbml_v5_15` (`ALTER TYPE … ADD VALUE 'NU' + AFTER 'BU'`, `UPDATE app_user SET email = lower(email)`; downgrade пересоздаёт enum); `DBML_VERSION = "5.15"`, `Makefile`, + `dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация + email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр. + Проверка: `make dbml-check` пуст; `make migration-cycle`. +- [ ] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`; + `FakeStorage.get_bytes`; `tests/factories.py`: `make_stored_file(session, *, entity, status, content_hash, job_status)`, + `make_document`, `make_entries_from_result(session, file, result_json)`; `CommittedJobs.cleanup()` удаляет `osv_entry`, + `document`. Настройки `UPLOAD_MAX_BYTES` (50 МиБ), `MAPPING_POLL_SECONDS` (5), `MAPPING_MAX_ATTEMPTS` (3) + `.env.example`/`.env.test`. + +## 2. Загрузка и статусы + +- [ ] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py` + (`upload_many`, дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`), + `schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт + `POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль); + `fixtures`-тест: реальный xls принят и `publish_pending` на `FakeNats` публикует запрос с ключом `uploads/{file_id}/{job_id}/…`. +- [ ] 2.2 **Статусы файла в обмене.** `recognition_exchange.py`: ack → `PROCESSING`; `error` → `ERROR`; вахтёр `timeout` → `ERROR`; + публикатор `dlq` → `ERROR` (все — только при `current_recognition_job_id = job`); `ResultMapper`/`NoopMapper` удалены, + `_map_result` убран, тесты inbox обновлены (`RecordingMapper` → проверка статуса файла). Тесты: сценарии спеки + `recognition-job-exchange` (ack → PROCESSING, error → ERROR, timeout → ERROR, done → файл остаётся PROCESSING). + +## 3. Маппинг + +- [ ] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc, + chart, resolve)` (пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`; + `repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None). + Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json` + и golden `ОСВ_66 за 2024`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа. +- [ ] 3.2 **Цикл маппинга.** `services/result_mapping.py`: `pick_candidate` (FOR UPDATE SKIP LOCKED), проверка ключа (OQ-70), чтение + S3 вне транзакции, `map_file` (одна транзакция: очистка прежних строк файла, `document`×N с обработкой повтора счёта, + пакетная вставка `osv_entry`, `group_schema`, `is_cumulative` по Q1 (D-9), `metrics.mapping_warnings`, `PROCESSED`), + повторы `mapping_attempts` → `ERROR`, `run_mapping_loop` в lifespan (`ExchangeTasks`). Интеграционные тесты: сценарии спеки + `osv-result-mapping` на `FakeStorage` с golden JSON (`ОСВ_66`), ключ вне папки, сбой с повтором (monkeypatch), идемпотентность, + два листа одного счёта, MISMATCH шапки, нарастающая по Q1, RECOGNIZED. +- [ ] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит + `FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два + разных счёта не заменяют друг друга, `include_superseded`. +- [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` + → файл + задание `done` + маппинг через `map_file`; инварианты спеки (строки без TOTAL, родители, суммы, `account_id`). + Время: ≤ 60 с на все 82. + +## 4. API и удаление + +- [ ] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты + `GET /legal-entities/{id}/files`, `GET /files/{id}`, `GET /documents/{id}`, `GET /documents/{id}/entries`, + `POST /documents/{id}/confirm`; policy через ЮЛ-владельца. Тесты: сценарии спеки `documents-api`; `test_openapi_routes`. +- [ ] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, + `s3_object.expires_at`, аудит `FILE_DELETE`. Тесты: сценарии спеки. + +## 5. Сквозной прогон и документы + +- [ ] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле + `services` (реальный `tabular-parser` против общего NATS/MinIO) — загрузка `rb/2024-FY/ОСВ_66 за 2024.xls` через API ядра, + ожидание `PROCESSED` (≤ 60 с), проверка документа и 20 строк; без профиля — skip с причиной. `make e2e` цель; `check-full` + его включает (skip допустим). +- [ ] 5.2 **Документы.** README (загрузка: curl-пример, статусы файла и документа, цикл маппинга и настройки, API, удаление и замена, + ограничения), CLAUDE.md (DBML v5.15, `domain/osv_mapping.py`, `services/primary_files.py`, `services/result_mapping.py`, + три фоновых цикла), `qa-guide` в docs — сценарии загрузки (в docs-change); `make check-full` зелёный; `tasks.md` `[x]`. +- [ ] 5.3 После merge: `openspec archive slice-2c-osv-ingest`. -- 2.47.2 From 55e538ce9d272f03f46cfa28d8d5f3463f5104b9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:14:30 +0400 Subject: [PATCH 02/35] =?UTF-8?q?chore:=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=202c=20(12=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=20+=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../plans/2026-10-05-slice-2c-osv-ingest.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md new file mode 100644 index 0000000..f877883 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -0,0 +1,328 @@ +# Срез 2c — загрузка ОСВ, цикл маппинга, документы (arm-core) — план исполнения + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/14-slice-2c-osv-ingest` (worktree `.wt/slice-2c/arm-core`). **Issue:** RSHB/arm-core#14. **PR:** #15 (открыт, спека). +**Соседний worktree:** docs `.wt/slice-2c/docs` (ветка `feat/16-slice-2c-osv-ingest`, issue #16, PR #17) — там уже лежит `model/data-model-v5.15.dbml`; задачи docs-change 1.2 и 2.1 выполняются в Task 1 и Task 12 этого плана. `../fixtures` — симлинк, только чтение (golden-снимки парсера `golden/expected_osv_parse/**`). `../tabular-parser` — симлинк на main, только чтение (для e2e через compose). +**Spec:** `openspec/changes/slice-2c-osv-ingest/` — proposal.md, design.md (D-1…D-13, таблица решений №1–13), specs/{primary-files,osv-result-mapping,documents-api,recognition-job-exchange,data-model,audit-trail}/spec.md. Правила: `../docs/context/05-domain-rules.md` §1.3 (строки, нормализация кода счёта), §1.5 (период), §3 п. 4 (резолв контрагента), §9.2–9.4 (статусы). Спека — единственный источник истины при рулингах; план сгенерирован из `tasks.md` change'а (номера в скобках). + +**Goal:** файл ОСВ, загруженный через API ядра, проходит очередь, распознаётся tabular-parser'ом и превращается в `document` + `osv_entry` без участия человека; статусы файла и документа объяснимы по карточке файла. + +**Architecture:** эндпоинт загрузки → `s3_object` + `stored_file` + `recognition_job(queued)` (outbox существующий) → inbox ставит статусы файла по ack/ошибке → отдельный фоновый цикл маппинга читает результат из S3 и одной транзакцией пишет документы и строки, автозамена прежних версий → API чтения и подтверждения. Чистые функции маппинга — `src/domain/osv_mapping.py`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, pydantic v2 (контракт v1.1 в `src/contract`), miniopy-async, pytest-asyncio, httpx. + +## Global Constraints + +- **DBML v5.15** (`../docs/model/data-model-v5.15.dbml`, 43 таблицы, 27 enum): `osv_data_type + NU`, `app_user.email` в нижнем регистре; полей вне DBML нет. `make dbml-check` — критерий Task 1 и регрессия в каждой задаче. +- **Статусы файла** меняет только код, проверивший `stored_file.current_recognition_job_id == job.id` (D-5): загрузка `UPLOADED`; inbox ack → `PROCESSING`; inbox `error`, вахтёр `timeout`, публикатор `dlq` → `ERROR`; цикл маппинга → `PROCESSED`/`ERROR`; автозамена → `SUPERSEDED`. +- **Inbox не читает S3 и не создаёт документы**; `ResultMapper`/`NoopMapper` удаляются. Маппинг — `services/result_mapping.py`, чтение S3 вне транзакции, одна транзакция на файл, перед вставкой — очистка строк/документов файла (идемпотентность). +- **Строки**: все, кроме `group_type = TOTAL` (OQ-01); `data_type` как пришёл (включая `NU`); суммы — `Decimal` из контракта без изменений; `counterparty_*` как в результате; `is_modified = false`; `parent_entry_id` по `parent_row_number`. +- **Резолв контрагента** только для `COUNTERPARTY` с `data_type ∈ {BU, SUM}`: ИНН → точное имя → `normalize_alias` по именам ЮЛ и алиасам; > 1 кандидата → `NULL`; без policy. Имя ЮЛ нормализуется той же `normalize_alias`. +- **Статус документа**: `RECOGNIZED` только при `job.status = done`, пустых `checks`-нарушениях, `counterparty_check ≠ MISMATCH` и без предупреждений маппинга; иначе `NEEDS_CHECK`. `period_check = UNKNOWN` (Срез 4). +- **Права и policy**: `DOCUMENT_EDIT` (загрузка, удаление, подтверждение), `DOCUMENT_VIEW` (чтение); видимость через ЮЛ-владельца (`legal_entities_visible`); чужое → 404 с тем же телом, что несуществующее. +- **Сессии** на маршрутах — только `SessionDep`; фоновые циклы — `async_session_maker`/`session_transaction`. `audit_event` — только `AuditService.record`/`RequestAudit.record` (append-only, тест-страж). Ошибки — `DomainError` (`{"detail": {"code", "message"}}`), 413 — `HTTPException` строкой как в импорте. +- **Деньги** — `Decimal`/`Numeric(18,2)`; `float` в домене запрещён. +- **Линтеры**: ruff (120, E,F,I,UP,B,PL — константы вместо магических чисел, `# noqa: PLR0913 - причина`), mypy (strict-флаги для `src.domain.*`, `src.models.*`). Тесты: маркеры `integration`, `fixtures`, новый `e2e` (объявить в `pytest.ini`). Команды — из `api/` (`make` из корня). Compose `arm-ka` поднят (PostgreSQL/NATS/MinIO) — не останавливать. +- **Коммиты** — русский, по задаче; трейлеры `Co-Authored-By: Claude <модель исполнителя> ` и `Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2`. Не пушить, не создавать PR, не `openspec archive`, `openspec/specs/` не трогать (кроме как через change). Коммиты в `../docs` — в его worktree, те же правила. +- Допущения: `# ASSUMPTION(OQ-02)` (нарастающая по Q1), `# ASSUMPTION(OQ-55)` (обмен не аудируется), `# ASSUMPTION(OQ-100)` (ИНН из текста не извлекается), `# ASSUMPTION(OQ-103)` (нечисловой первый сегмент → `account_root = None`), `# ASSUMPTION(OQ-22)` (`expires_at` при удалении). + +## Review Focus + +1. **Два задания у одного файла** (повторная загрузка того же содержимого после `SUPERSEDED`, или будущий replay): статус файла меняет только текущее задание; результат старого задания после смены `current_recognition_job_id` файл не трогает — Task 4 (inbox) и Task 7 (цикл): тест «результат чужого задания игнорируется». +2. **Результат `needs_check` при отсутствии документов** (парсер по контракту требует `documents` ≥ 1, но файл может прийти от другого сервиса): цикл маппинга → `ERROR` «нет документов ОСВ», не исключение — Task 7: тест. +3. **Строка `COUNTERPARTY` с ИНН, которого нет в справочнике, но имя совпадает с алиасом другого ЮЛ** — ИНН не найден → переход к имени/алиасу по §3 (ИНН не блокирует) — Task 6: тест. +4. **Имя файла с кириллицей, пробелами и `..`** в ключе S3 — `safe_filename` + ключ без путей; загрузка файла с пустым именем → `UNSUPPORTED_FILE` — Task 3: тест. +5. **Подтверждение документа файла, который только что заменён автозаменой** — гонка «подтвердить» vs «цикл заменил»: `confirm` перечитывает файл под `FOR UPDATE` и отвечает 409 `FILE_SUPERSEDED` — Task 9: тест. + +--- + +## Задачи + +### Task 1: [1.1] DBML v5.15, миграция 0003, email в нижнем регистре + +**Files:** +- Modify: `api/src/models/enums.py` (`OsvDataType.NU` после `BU`), `api/src/models/__init__.py` (`DBML_VERSION = "5.15"`), `Makefile` (`DBML ?= ../../docs/model/data-model-v5.15.dbml`), `api/scripts/dbml_parity.py` (`DBML_FILE_NAME`), `api/tests/test_migrations.py` (`HEAD = "0003_dbml_v5_15"` + тест отката на шаг), `api/tests/test_enums.py`, `api/tests/test_check_dbml_script.py` (фиктивная `v5.16`), `api/scripts/seed_lib/apply.py` (`_apply_users`: `email.strip().lower()`), `api/src/services/users_admin.py` (`create_user`/`update_user`: хранить `email.strip().lower()`; pre-check по `lower` остаётся), `openspec/specs` не трогать; docstring'и «v5.14» → «v5.15» там, где речь о текущей версии +- Create: `api/alembic/versions/0003_dbml_v5_15.py` +- Create (worktree docs `.wt/slice-2c/docs`): `model/entities-v5.15.md` (копия v5.14 + «Что изменилось в v5.15»: `NU`, email), правки `context/04-data-model-guide.md`, `repo-templates/arm-core.CLAUDE.md`, workspace `/Users/andrey.epifancev/projects/awp-ca/CLAUDE.md` (вне git) → v5.15; `openspec/changes/slice-2c-osv-ingest/tasks.md` 1.2 → `[x]`; коммит в docs + +**Interfaces:** +- Миграция (стиль 0002): `upgrade`: `ALTER TYPE dkb.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'`; `UPDATE dkb.app_user SET email = lower(email) WHERE email <> lower(email)`; `downgrade`: пересоздание `osv_data_type` со значениями v5.14 (`RENAME TYPE` → `CREATE TYPE` → `ALTER COLUMN osv_entry.data_type TYPE … USING data_type::text::dkb.osv_data_type` → `DROP TYPE`), email не возвращается. +- `OsvDataType`: `BU, NU, CURRENCY, QUANTITY, SUM` (порядок DBML). + +- [ ] **Step 1: Тесты.** `test_enums` ожидает `"5.15"` и 5 значений `osv_data_type`; `test_migrations`: `HEAD`, `test_downgrade_one_step` → после `downgrade 0002_dbml_v5_14` `enum_range(NULL::dkb.osv_data_type)` без `NU`; тест данных: вставить `app_user` с `Email@X.RU` на ревизии 0002 → после `upgrade head` email `email@x.ru`; `test_seed_dev`: email dev-пользователей в нижнем регистре; `test_api_admin`: создание с `Qa1@Example.com` → хранится `qa1@example.com`, `/me` по любому регистру. +- [ ] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_seed_dev.py tests/test_api_admin.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003` (+ коммит в docs `docs: entities-v5.15, guide, ссылки на DBML v5.15`). + +--- + +### Task 2: [1.2] S3 `get_bytes`, фабрики, настройки + +**Files:** +- Modify: `api/src/core/clients.py` (`S3Storage.get_bytes`), `api/src/services/directory_import.py` (`ObjectStorage` протокол → вынести в `api/src/core/storage.py:ObjectStorage(Protocol)` с `bucket`, `put_object`, `get_bytes`), `api/tests/conftest.py` (`FakeStorage.get_bytes`, `KeyError` → `ObjectNotFoundError`), `api/tests/factories.py`, `api/src/core/config.py` (`UPLOAD_MAX_BYTES: PositiveInt = 50 * 1024 * 1024`, `MAPPING_POLL_SECONDS: PositiveInt = 5`, `MAPPING_MAX_ATTEMPTS: PositiveInt = 3`), `api/.env.example`, `api/.env.test`, `api/pytest.ini` (маркер `e2e`) +- Test: `api/tests/test_clients.py` (unit на `get_bytes` с заглушкой Minio), `api/tests/test_factories.py` + +**Interfaces:** +```python +# core/storage.py +class ObjectNotFoundError(Exception): ... +class ObjectStorage(Protocol): + bucket: str + async def put_object(self, obj_name: str, file_data: bytes) -> None: ... + async def get_bytes(self, obj_name: str) -> bytes: ... # отсутствие объекта → ObjectNotFoundError +# S3Storage.get_bytes: response = await self.client.get_object(self.bucket, obj_name); try: return await response.read() finally: response.close(); await response.release() +# (miniopy-async: NoSuchKey → ObjectNotFoundError) +# tests/factories.py +async def make_stored_file(session, *, entity: LegalEntity, status=FileStatus.UPLOADED, file_type=FileType.XLS, original_name="osv.xls", + content_hash: str | None = None, job_status=RecognitionStatus.queued, s3_key: str | None = None, + uploaded_by: uuid.UUID | None = None) -> tuple[StoredFile, RecognitionJob] # s3_object + stored_file + job + current_recognition_job_id, flush +async def make_document(session, *, file: StoredFile, job: RecognitionJob, account="60", year=2025, quarter=PeriodQuarter.Q1, + status=DocumentStatus.RECOGNIZED, is_cumulative=True) -> Document +async def make_result_object(session, *, job: RecognitionJob, storage: FakeStorage, result: dict) -> S3Object # кладёт JSON в storage под results/{file_id}/{job_id}/result.json, регистрирует s3_object, job.result_s3_object_id +# CommittedJobs.cleanup(): перед stored_file удалять osv_entry, document (по file_id) +``` + +- [ ] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`. +- [ ] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`. + +--- + +### Task 3: [2.1] Загрузка файлов + +**Files:** +- Create: `api/src/domain/file_types.py`, `api/src/services/primary_files.py`, `api/src/schemas/files.py`, `api/src/api/endpoints/files/__init__.py`, `api/src/api/endpoints/files/upload.py`, `api/src/api/dependencies/files.py` +- Modify: `api/src/api/router.py` (include `files.router`), `api/src/services/directory_import.py` (`safe_filename`, `CONTENT_TYPES` → вынести в `api/src/common/filenames.py` и переиспользовать) +- Test: `api/tests/test_domain_file_types.py`, `api/tests/test_api_files_upload.py` + +**Interfaces:** +```python +# domain/file_types.py +@dataclass(frozen=True) +class DetectedFile: file_type: FileType; pipeline: RecognitionPipeline; content_type: str +def detect_file(name: str, content: bytes) -> DetectedFile # расширение → тип; сигнатура: xls = b"\xd0\xcf\x11\xe0", xlsx/xlsb = b"PK\x03\x04", pdf = b"%PDF", png = b"\x89PNG", jpg = b"\xff\xd8\xff"; несовпадение/неизвестно → DomainValidationError("UNSUPPORTED_FILE", …) +MAX_ATTEMPTS = {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1} +# services/primary_files.py +@dataclass(frozen=True) +class AcceptedFile: file_id: uuid.UUID; job_id: uuid.UUID; name: str +@dataclass(frozen=True) +class RejectedFile: name: str; code: str; message: str; file_id: uuid.UUID | None = None +class PrimaryFilesService: + def __init__(self, session, audit: RequestAudit, scope: VisibilityScope, storage: ObjectStorage | None, settings: Settings) -> None + async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile # raises DomainError → эндпоинт превращает в RejectedFile; каждая загрузка — begin_nested() (SAVEPOINT) внутри транзакции запроса, чтобы отказ одного файла не откатил принятые + async def find_duplicate(self, entity_id, sha256) -> StoredFile | None # status != SUPERSEDED +# endpoint: POST /legal-entities/{legal_entity_id}/files, files: list[UploadFile] = File(...), require("DOCUMENT_EDIT"); ЮЛ через get_legal_entity(session, scope, id) → 404; storage None → HTTPException(503, "хранилище файлов недоступно") (код STORAGE_UNAVAILABLE в detail-строке); размер — _read_limited(file, settings.UPLOAD_MAX_BYTES) → 413 превращается в RejectedFile(FILE_TOO_LARGE) (не HTTPException на весь запрос); ответ UploadResult(accepted, rejected), 201 если accepted непуст, иначе 422 +# ключ S3: f"uploads/{file_id}/{job_id}/{safe_filename(name)}"; S3Object (bucket_role=uploads, sha256, size_bytes, content_type, created_by=f"user:{id}", created_at) → flush → put_object → StoredFile(status=UPLOADED, version_no=1, content_hash=sha256, original_name=safe name, uploaded_by, uploaded_at) → RecognitionJob(file_id, pipeline, request_subject=request_subject(pipeline) из contract.subjects, schema_version=CONTRACT_VERSION, status=queued, max_attempts) → flush → file.current_recognition_job_id = job.id; audit FILE_UPLOAD (object_id=file.id, after_value={original_name, content_hash, job_id, file_type}) +``` +- `schemas/files.py`: `UploadResult{accepted: list[AcceptedOut], rejected: list[RejectedOut]}`, `JobBrief{id, status, error_code, error_message, warnings: list[str], mapping_warnings: list[str], finished_at}`, `DocumentBrief{id, document_type, osv_account_code, period_year, period_quarter, is_cumulative, status, period_check, counterparty_check, locator}`, `FileOut{id, legal_entity_id, original_name, file_type, status, version_no, size_bytes, content_hash, uploaded_at, uploaded_by, superseded_by_file_id, job: JobBrief | None, documents: list[DocumentBrief]}` (используется Task 9). + +- [ ] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите. +- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`. +- [ ] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`. + +--- + +### Task 4: [2.2] Статусы файла в обмене, удаление `ResultMapper` + +**Files:** +- Modify: `api/src/services/recognition_exchange.py` (`_apply_ack` → `PROCESSING`; `_apply_result` при `error` → `ERROR`; `expire_deadlines` — второй `UPDATE stored_file`; `_process_one` при `dlq` → `ERROR`; удалить `ResultMapper`, `NoopMapper`, `_map_result`, `_MapperError`, параметр `mapper` у `handle_message`), `api/src/app.py` (`partial(handle_message, session_factory=...)`), `api/tests/test_recognition_inbox.py`, `api/tests/test_recognition_exchange.py` (`RecordingMapper` → проверки статуса файла) +- Create: `api/src/services/file_status.py` + +**Interfaces:** +```python +# services/file_status.py +async def set_file_status_for_job(session, job: RecognitionJob, status: FileStatus, *, now: datetime) -> bool +# UPDATE stored_file SET status=:status, updated_at=:now WHERE id = job.file_id AND current_recognition_job_id = job.id AND status NOT IN (SUPERSEDED, PROCESSED); returns rowcount == 1 +# в expire_deadlines: после UPDATE recognition_job … RETURNING id, file_id → UPDATE stored_file SET status='ERROR' WHERE current_recognition_job_id = ANY(ids) AND status NOT IN (SUPERSEDED, PROCESSED) +``` +Ошибка задания (`error`/`timeout`/`dlq`) ставит файлу `ERROR`; ack — `PROCESSING` только из `UPLOADED`; `done`/`needs_check` файл не трогают. + +- [ ] **Step 1: Тесты** (integration, `CommittedJobs` + `make_stored_file`): ack → `PROCESSING`; `error` → `ERROR`; `done` → файл `PROCESSING`; `timeout` вахтёром → `ERROR`; `dlq` публикатором → `ERROR`; результат задания, которое уже не `current_recognition_job_id` файла → файл не меняется (Review Focus 1); существующие тесты inbox без `mapper` зелёные. +- [ ] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_recognition_inbox.py tests/test_recognition_exchange.py -q`, `make check`. +- [ ] **Step 5: Commit** `feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)`. + +--- + +### Task 5: [3.1a] Домен маппинга — строки, нормализация, проверки + +**Files:** +- Create: `api/src/domain/osv_mapping.py` +- Test: `api/tests/test_domain_osv_mapping.py` + +**Interfaces:** +```python +ACCOUNT_SEGMENT_DIGITS = 2 +def normalize_account_code(raw: str) -> str # "60.1"→"60.01", "76.7.1"→"76.07.1", "58.03"→"58.03", "76.АВ"→"76.АВ", "МЦ.04"→"МЦ.04", "001"→"001"; сегменты по '.', числовой сегмент (1–3 цифры) → zfill(2) кроме первого (как есть) и третьего (как есть) +def account_root(code: str) -> str | None # первый сегмент, если он из цифр; иначе None (ASSUMPTION(OQ-103)) +@dataclass(frozen=True) +class EntryDraft: id: uuid.UUID; row_number: int; level: int; parent_id: uuid.UUID | None; row_value: str; group_type: OsvGroupType; group_name: str | None; data_type: OsvDataType | None; account_code: str | None; account_root: str | None; account_id: uuid.UUID | None; counterparty_name: str | None; counterparty_inn: str | None; sums: tuple[Decimal | None, ...] # opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit +@dataclass(frozen=True) +class MappedSheet: entries: list[EntryDraft]; root: EntryDraft | None; total_present: bool; orphans: tuple[int, ...] +def build_entries(doc: OsvDocument, chart: Mapping[str, uuid.UUID]) -> MappedSheet +# пропуск TOTAL; id = uuid4 заранее; parent по parent_row_number (нет в карте → parent None; MappedSheet.orphans — список row_number, вызывающий пишет предупреждение); account_code = row.account_code_raw; account_id = chart.get(normalize_account_code(account_code)) if account_code else None; group_type TOTAL не встречается в entries; data_type через OsvDataType(row.data_type) if row.data_type else None; counterparty_* как есть +def document_fields(doc: OsvDocument) -> DocumentDraft # osv_account_code = normalize_account_code(header.account_code_raw), period_year, period_quarter (PeriodQuarter), is_cumulative, locator +def counterparty_check(header: OsvHeader, owner_inn: str, owner_names_normalized: frozenset[str]) -> DocumentCheckResult +# organization_inn есть → MATCH если == owner_inn (только цифры), иначе MISMATCH; нет ИНН: normalize_alias(organization_name) ∈ owner_names_normalized → MATCH; пустое имя и ИНН → UNKNOWN; иначе MISMATCH +def document_status(*, job_done: bool, checks: OsvChecks | None, counterparty: DocumentCheckResult, warnings: Sequence[str]) -> DocumentStatus +# RECOGNIZED iff job_done and (checks is None or (checks.total_row_matches_root is not False and not checks.balance_identity_violations and not checks.parent_sum_violations)) and counterparty != MISMATCH and not warnings; else NEEDS_CHECK +def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool # ±0.01, None = 0 — для D-9 +``` + +- [ ] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01. +- [ ] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`. +- [ ] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`. + +--- + +### Task 6: [3.1b] Резолв контрагента + +**Files:** +- Create: `api/src/repositories/counterparties.py` +- Test: `api/tests/test_counterparty_resolve.py` + +**Interfaces:** +```python +@dataclass +class CounterpartyIndex: + by_inn: dict[str, uuid.UUID] + by_exact_name: dict[str, set[uuid.UUID]] # strip() + by_normalized: dict[str, set[uuid.UUID]] # normalize_alias(name) ∪ normalized_alias алиасов + @classmethod + async def load(cls, session) -> "CounterpartyIndex" # два запроса: legal_entity(id, inn, name), legal_entity_alias(legal_entity_id, normalized_alias); без policy + def resolve(self, inn: str | None, name: str | None) -> uuid.UUID | None + # inn (цифры) in by_inn → id; else name.strip() in by_exact_name и ровно один → id (> 1 → None, 0 → дальше); else normalize_alias(name) in by_normalized и ровно один → id; иначе None + def owner_names(self, entity: LegalEntity) -> frozenset[str] # normalize_alias(name) ∪ его алиасы — для counterparty_check +``` + +- [ ] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`. +- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. +- [ ] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`. + +--- + +### Task 7: [3.2] Цикл маппинга + +**Files:** +- Create: `api/src/services/result_mapping.py` +- Modify: `api/src/services/recognition_exchange.py` (`ExchangeTasks.start` принимает `storage` и добавляет задачу `run_mapping_loop`), `api/src/app.py` (`_start_exchange(nats, storage)`; цикл маппинга стартует вместе с обменом, при `NATS_ENABLED=false` не стартует) +- Test: `api/tests/test_result_mapping.py` + +**Interfaces:** +```python +MAPPING_ATTEMPTS_KEY = "mapping_attempts"; MAPPING_ERROR_KEY = "mapping_error"; MAPPING_WARNINGS_KEY = "mapping_warnings" +class MappingFatal(Exception): """ошибка без повтора (ключ, объект, схема, нет документов)""" +@dataclass(frozen=True) +class Candidate: job_id: uuid.UUID; file_id: uuid.UUID; bucket: str; key: str +async def pick_candidate(session) -> Candidate | None +# SELECT job.id, job.file_id, s3.bucket, s3.key FROM recognition_job job JOIN stored_file f ON f.id=job.file_id AND f.current_recognition_job_id=job.id JOIN s3_object s3 ON s3.id=job.result_s3_object_id +# WHERE job.status IN (done, needs_check) AND f.status='PROCESSING' AND coalesce((job.metrics->>'mapping_attempts')::int,0) < :max ORDER BY job.finished_at, job.id LIMIT 1 FOR UPDATE OF job SKIP LOCKED +def validate_result_key(candidate: Candidate, storage_bucket: str) -> None # MappingFatal при бакете ≠ storage.bucket или ключе не вида results/{file_id}/{job_id}/… +async def map_file(session, *, job: RecognitionJob, file: StoredFile, result: RecognitionResult, chart: Mapping[str, uuid], index: CounterpartyIndex, now: datetime) -> list[str] # предупреждения +# в транзакции: DELETE osv_entry WHERE document_id IN (документы файла); DELETE document WHERE stored_file_id; по osvDocument: document_fields → если osv_account_code уже встречался в этом файле → warning "sheet=: счёт <код> уже есть в листе ", первый документ помечается needs_check; иначе Document(...); build_entries → insert(OsvEntry).values([...]) пакетами по 1000; D-9: для Q2–Q4 с is_cumulative False — ищем Document того же ЮЛ/счёта/года Q1 с файлом != SUPERSEDED, корень = osv_entry с row_number минимальным level 0 → opening_matches → is_cumulative True + warning; counterparty_check(header, file.legal_entity.inn, index.owner_names(entity)); status = document_status(...); job.group_schema = [{"locator", "groups":[{"type","name","has_inn"}]}]; job.metrics[mapping_warnings] = warnings; file.status = PROCESSED, updated_at; вызов supersede_previous добавляет Task 8; в этой задаче map_file завершается установкой PROCESSED +async def process_once(session_factory, storage: ObjectStorage, settings, *, now=None) -> bool +# 1) короткая транзакция: pick_candidate → нет → False; validate_result_key (MappingFatal → в той же транзакции файл ERROR, metrics.mapping_error, commit, return True) +# 2) без транзакции: content = await storage.get_bytes(key) (ObjectNotFoundError → fatal); RecognitionResult.model_validate_json (ValidationError → fatal); нет osvDocument → fatal "нет документов ОСВ" +# 3) транзакция: заново SELECT job FOR UPDATE и file; если file.status != PROCESSING или current_job != job → return True (кто-то опередил); chart = {code: id} одним запросом; index = CounterpartyIndex.load; map_file; commit +# исключение в 3): rollback; отдельная транзакция: metrics.mapping_attempts += 1; если >= MAPPING_MAX_ATTEMPTS → file ERROR + mapping_error; лог с traceback +async def run_mapping_loop(session_factory, storage, settings, *, sleep=asyncio.sleep) -> None # while True: processed = await process_once(...); await sleep(0 if processed else settings.MAPPING_POLL_SECONDS); исключения цикла логируются, цикл не умирает +``` +`ExchangeTasks.start(session_factory, nats, settings, storage)` — третья задача `recognition-result-mapper` только при `storage is not None` (иначе warning «S3 не подключено: маппинг результатов не запущен»). + +- [ ] **Step 1: Тесты** (integration, `committed_sessionmaker` + `FakeStorage`; результат — golden JSON `ОСВ_66 за 2024.json` с подставленными `job_id`/`file_id`): сценарии спеки `osv-result-mapping` «Результат смапплен» (документ 66/2024/Q4/cumulative, `locator`, 20 строк, `group_schema`), «Ключ вне папки» → `ERROR`, «Сбой с повтором» (monkeypatch `map_file` бросает 2 раза → attempts, 3-й → `ERROR`), «Идемпотентность» (вставить половину строк вручную → `process_once` → ровно один набор), «Два листа одного счёта» (JSON с двумя документами `60`) → один документ `NEEDS_CHECK` + warning, «Шапка другой организации» → `MISMATCH`/`NEEDS_CHECK`, «Нарастающая по данным» (сначала документ Q1 через фабрики с корнем, затем Q3 с теми же opening) → `is_cumulative = True`, «Чистый результат» → `RECOGNIZED`; результат без документов (`status: error`-подобный JSON с `documents: []` не валиден по контракту → fatal) → `ERROR` (Review Focus 2); результат для задания, которое уже не текущее → не трогает файл (Review Focus 1); `run_mapping_loop` с заглушкой sleep обрабатывает два кандидата подряд. +- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_result_mapping.py -q`, `make check`. +- [ ] **Step 5: Commit** `feat(mapping): фоновый цикл маппинга результата в document/osv_entry (OQ-69, OQ-70)`. + +--- + +### Task 8: [3.3] Автозамена + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`supersede_previous`), `api/src/services/audit.py` — без изменений (системное событие `user_id=None`) +- Test: `api/tests/test_result_mapping.py` (+ сценарии) + +**Interfaces:** +```python +async def supersede_previous(session, *, new_file: StoredFile, new_documents: Sequence[Document], uploader_id: uuid.UUID | None, now) -> list[StoredFile] +# для каждого нового документа: SELECT stored_file JOIN document WHERE stored_file.legal_entity_id = new.legal_entity_id AND stored_file.id != new.id AND stored_file.status != SUPERSEDED AND document.(type, osv_account_code, period_year, period_quarter) совпадают; distinct файлы → status SUPERSEDED, superseded_by_file_id = new.id, updated_at; new.version_no = max(v.version_no) + 1 (если нашлись); AuditService(session).record(action=FILE_REPLACE, section=PRIMARY_DOCUMENTS, object_type="stored_file", object_id=old.id, user_id=None, after_value={"file_id": new.id, "version_no": new.version_no}, description=f"заменён файлом {new.original_name} (загрузил user:{uploader_id}); документы: OSV 60 2025 Q1 (CONFIRMED)…; confirmed={true|false}") +``` +Вызывается в `map_file` после установки `PROCESSED`, в той же транзакции. + +- [ ] **Step 1: Тесты:** сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (старый `CONFIRMED` → `SUPERSEDED`, `version_no = 2`, аудит с `confirmed=true`); два разных счёта не заменяют друг друга; тот же счёт другого года не заменяет; заменённый файл не появляется в `GET files` без `include_superseded` (проверяется в Task 9, здесь — по БД). +- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. +- [ ] **Step 5: Commit** `feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)`. + +--- + +### Task 9: [4.1] API чтения и подтверждение документа + +**Files:** +- Create: `api/src/repositories/files.py`, `api/src/services/documents.py`, `api/src/schemas/documents.py`, `api/src/api/endpoints/files/read.py`, `api/src/api/endpoints/files/documents.py` +- Modify: `api/src/api/endpoints/files/__init__.py`, `api/src/api/dependencies/files.py`, `api/tests/test_openapi_routes.py` +- Test: `api/tests/test_api_files_read.py`, `api/tests/test_api_documents.py` + +**Interfaces:** +```python +# repositories/files.py +async def list_files(session, scope, entity_id, *, include_superseded: bool, limit, offset) -> tuple[list[StoredFile], int] # с joinedload job (current) и documents (selectin) +async def get_file(session, scope, file_id) -> StoredFile | None # через legal_entities_visible по владельцу +async def get_document(session, scope, document_id) -> Document | None +async def list_entries(session, document_id, *, limit, offset) -> tuple[list[OsvEntry], int] # ORDER BY row_number +# services/documents.py +class DocumentService: + async def confirm(self, document_id) -> Document # SELECT document JOIN stored_file FOR UPDATE; file.status == SUPERSEDED → ConflictError("FILE_SUPERSEDED"); status == CONFIRMED → ConflictError("ALREADY_CONFIRMED"); status ∉ {RECOGNIZED, NEEDS_CHECK} → ConflictError("DOCUMENT_NOT_CONFIRMABLE"); set CONFIRMED, confirmed_by, confirmed_at; audit DOCUMENT_CONFIRM before/after status +# schemas/documents.py: DocumentOut{id, file_id, legal_entity_id, document_type, osv_account_code, period_year, period_quarter, is_cumulative, locator, status, period_check, counterparty_check, confirmed_by, confirmed_at, group_schema: list[GroupOut] | None, warnings: list[str]}; EntryOut{id, row_number, level, parent_entry_id, row_value, group_type, group_name, data_type, account_code, account_root, account_id, counterparty_name, counterparty_inn, counterparty_id, opening_debit…closing_credit (Decimal → str)}; EntriesPageParams(limit ≤ 2000, default 500) +# маршруты: GET /legal-entities/{id}/files → Page[FileOut]; GET /files/{file_id} → FileOut; GET /documents/{id} → DocumentOut; GET /documents/{id}/entries → Page[EntryOut]; POST /documents/{id}/confirm → DocumentOut +``` + +- [ ] **Step 1: Тесты:** сценарии спеки `documents-api` (список с `PROCESSED`+документом и `ERROR`, `include_superseded`, карточка, 404 для чужого, строки деревом на данных из golden через `make_entries_from_result`, `limit=2001` → 422, подтверждение с аудитом, повтор → 409, документ `SUPERSEDED` файла → 409; гонка Review Focus 5: файл переведён в `SUPERSEDED` между чтением и `confirm` — имитация прямым UPDATE перед вызовом → 409); `test_openapi_routes` — новые пути. +- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. +- [ ] **Step 5: Commit** `feat(files): API файлов, документов и строк ОСВ; подтверждение документа`. + +--- + +### Task 10: [4.2] Удаление файла + +**Files:** +- Modify: `api/src/services/primary_files.py` (`delete`), `api/src/api/endpoints/files/read.py` (`DELETE /files/{file_id}`), `api/src/repositories/files.py` +- Test: `api/tests/test_api_files_delete.py` + +**Interfaces:** +```python +async def delete(self, file_id) -> None # get_file(scope) → 404; есть document CONFIRMED → ConflictError("FILE_HAS_CONFIRMED_DOCUMENTS"); EXISTS stored_file WHERE superseded_by_file_id = id → ConflictError("FILE_SUPERSEDES_OTHERS"); before = FileOut-словарь; DELETE osv_entry (по документам файла), document, recognition_job WHERE file_id (сначала file.current_recognition_job_id = NULL + flush — FK цикл), stored_file; s3_object.expires_at = now (ASSUMPTION(OQ-22)); audit FILE_DELETE before_value=before +``` + +- [ ] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками). +- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. +- [ ] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`. + +--- + +### Task 11: [5.1] Сквозной прогон через compose + +**Files:** +- Create: `api/tests/e2e/__init__.py`, `api/tests/e2e/test_osv_ingest_e2e.py`, `api/tests/e2e/conftest.py` +- Modify: `Makefile` (`e2e: cd api && pytest -m e2e`, `check-full` включает `e2e`), `README.md` (как поднять профиль `services`) + +**Interfaces:** тест (маркеры `integration`, `e2e`, `fixtures`): предусловия — `curl localhost:8001/healthcheck` tabular-parser из профиля `services` (`docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build tabular-parser`) и `NATS_ENABLED=true` у тестового приложения; иначе `pytest.skip("профиль services не поднят")`. Шаги: полное приложение `src.app.app` (lifespan с реальными NATS/MinIO из `.env.test`, `NATS_ENABLED=true` через monkeypatch settings) на отдельной БД (`committed_sessionmaker` + seed) → `POST /legal-entities/{id}/files` для ЮЛ, созданного фабрикой с ИНН `3528052718` (АО «Русский бисквит», владелец ОСВ 66) с `rb/2024-FY/ОСВ_66 за 2024.xls` → ждать до 60 с `GET /files/{id}` → `PROCESSED` → документ 66/2024/Q4, 20 строк, `counterparty_check = MATCH`, `status = RECOGNIZED`; очистка: удалить файл через API. + +- [ ] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. +- [ ] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. + +--- + +### Task 12: [5.2] Документы и `make check-full` + +**Files:** +- Modify: `README.md` (разделы «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга» с настройками, «API файлов и документов», «Удаление и замена», «Ограничения»), `CLAUDE.md` (DBML v5.15, `domain/osv_mapping.py`, `services/primary_files.py`, `services/result_mapping.py`, три фоновых цикла, маркер `e2e`), `openspec/changes/slice-2c-osv-ingest/tasks.md` (все `[x]` кроме 5.3), `openspec/changes/slice-2c-osv-ingest/design.md` (рулинги контроллера) +- Modify (worktree docs): `context/05-domain-rules.md` (§1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 по факту), `context/03-architecture.md` (цикл маппинга), `context/07-open-questions.md` (закрыть OQ-67/69/70/59/109; новые OQ: удаление после консолидаций, ретеншн при удалении), `context/09-delivery-plan.md` (2c по факту), `dev/qa-guide.md` (раздел «Загрузка ОСВ и просмотр строк через API»: curl-сценарий, ожидания на `ОСВ_66`), `openspec/changes/slice-2c-osv-ingest/tasks.md` 2.1 → `[x]` + +- [ ] **Step 1:** таблица «требование спеки → сценарий → тест» в отчёте задачи; пропуски — добавить тесты. +- [ ] **Step 2:** документы; `openspec validate --strict` в обоих репозиториях. +- [ ] **Step 3:** `make check-full` (compose поднят; профиль `services` — по возможности, иначе e2e skip) → зелёный; вывод — в ledger. +- [ ] **Step 4: Commit** здесь `docs: README/CLAUDE.md Среза 2c, tasks.md`; в docs `docs: §1.3/§1.5/§3/§9 по факту маппинга, архитектура (цикл маппинга), OQ, план срезов, qa-guide`. + +--- + +## Завершение + +Финальное ревью всей ветки (opus): спека ↔ код по шести дельтам; инварианты (поля вне DBML v5.15, `audit_event` append-only, код роли, `float`, первичка не мутируется — `osv_entry` пишется один раз и только маппером); безопасность (policy на всех маршрутах файлов/документов, 404 vs 403, `safe_filename`, ключ результата OQ-70, лимит размера, 503 без S3); гонки (два цикла + inbox на `stored_file`, `confirm` vs автозамена); производительность маппинга (1045 строк `ОСВ_60 за 2024.xls` — цель < 2 с). Находки `C/I/M` со статусами; `deferred` — в PR и follow-up issue при закрытии. -- 2.47.2 From d905e3aa049f24be9b1e4e7be906066961526ece Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:17:05 +0400 Subject: [PATCH 03/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=BB=D0=B5=D1=80=D0=B0=20=D0=BF=D0=BE=20pre-flight=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=B0=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=202c?= =?UTF-8?q?=20(design=20=E2=84=9614=E2=80=9320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 4ca15f7..0293013 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -171,3 +171,10 @@ rows без TOTAL, у каждого не-корня есть родитель, | 11 | 2026-10-05 | D-11: `checks` парсера в БД не хранятся, карточка документа отдаёт статус и проверки ядра | DBML | колонка/чтение из S3 | | 12 | 2026-10-05 | D-13: e2e через профиль `services` помечен `e2e` и пропускается без профиля | нет CI | — | | 13 | 2026-10-05 | v5.15: email в нижнем регистре вместо expression-индекса `lower(email)` | сверка ORM↔DBML не умеет expression-индексы | индекс в v5.16 | +| 14 | 2026-10-05 | R-1: golden-прогон 82 снимков — второй коммит Task 8 (`tests/test_mapping_golden.py`) | в плане нет отдельной задачи под tasks 3.4; нужен полный `map_file` | перенос теста | +| 15 | 2026-10-05 | R-2: `make_entries_from_result` создаётся в Task 9, не в Task 2 | зависит от `build_entries` | нет | +| 16 | 2026-10-05 | R-3: `EntryDraft.counterparty_id`, проставляется в цикле маппинга через `CounterpartyIndex.resolve` для `COUNTERPARTY` + `BU/SUM`; `build_entries` без resolve | спека «Резолв контрагента» не сводилась с сигнатурой плана | сигнатура на callable (D-6) | +| 17 | 2026-10-05 | R-4: inbox при `done`/`needs_check` переводит файл `UPLOADED → PROCESSING` (текущее задание) | core NATS без JetStream: ack может потеряться, иначе файл с готовым результатом не попадёт в маппинг | убрать ветку | +| 18 | 2026-10-05 | R-5: ack/результат ставят `PROCESSING` только из `UPLOADED`; `ERROR`/`SUPERSEDED`/`PROCESSED` не перезаписываются | запоздавший ack не воскрешает файл после timeout | параметр `only_from` | +| 19 | 2026-10-05 | R-6: `DocumentOut.warnings` — `metrics.mapping_warnings` задания целиком, `group_schema` — по `locator` документа | в DBML нет привязки предупреждения к документу | фильтрация по листу | +| 20 | 2026-10-05 | R-7: в Task 1 правится workspace `CLAUDE.md` (вне git): только версия DBML | по плану Task 1 | откат текста | -- 2.47.2 From ab606dc59a78babee1e86c854b50b8f4aa16a11e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:28:04 +0400 Subject: [PATCH 04/35] =?UTF-8?q?feat(model):=20DBML=20v5.15=20=E2=80=94?= =?UTF-8?q?=20osv=5Fdata=5Ftype=20NU,=20email=20=D0=B2=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=B6=D0=BD=D0=B5=D0=BC=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B5,=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=200003?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OsvDataType.NU после BU, DBML_VERSION = 5.15, Makefile/dbml_parity/docstring'и на data-model-v5.15.dbml; - миграция 0003_dbml_v5_15: ADD VALUE 'NU' AFTER 'BU' + lower(app_user.email); downgrade пересоздаёт osv_data_type (регистр email не возвращается); - email хранится в нижнем регистре: users_admin.create_user и seed (_apply_users); с UNIQUE по строчному email гонка создания в другом регистре теперь ловится индексом (тест test_api_constraint_races обновлён); - тесты: enum NU, откат на шаг, lower email на миграции, seed/админка, вставка NU, фиктивная v5.16. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 8 +- api/alembic/versions/0003_dbml_v5_15.py | 42 +++++++ api/scripts/check_dbml_vs_orm.py | 2 +- api/scripts/dbml_parity.py | 2 +- api/scripts/seed_lib/apply.py | 14 ++- api/src/domain/policy.py | 2 +- api/src/models/__init__.py | 4 +- api/src/models/access.py | 2 +- api/src/models/accounting.py | 2 +- api/src/models/audit_ex.py | 2 +- api/src/models/base.py | 2 +- api/src/models/consolidation.py | 2 +- api/src/models/directories.py | 2 +- api/src/models/enums.py | 5 +- api/src/models/files.py | 2 +- api/src/models/kfo.py | 2 +- api/src/models/osv.py | 2 +- api/src/models/statements.py | 2 +- api/src/schemas/admin.py | 2 +- api/src/schemas/directories.py | 2 +- api/src/services/users_admin.py | 6 +- api/tests/test_api_admin.py | 21 +++- api/tests/test_api_constraint_races.py | 8 +- api/tests/test_check_dbml_script.py | 14 +-- api/tests/test_enums.py | 9 +- api/tests/test_migrations.py | 115 ++++++++++++++++-- api/tests/test_models_part_01_03.py | 2 +- api/tests/test_models_part_04_06.py | 29 ++++- api/tests/test_models_part_07_09.py | 4 +- api/tests/test_seed_dev.py | 19 +++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 34 files changed, 274 insertions(+), 68 deletions(-) create mode 100644 api/alembic/versions/0003_dbml_v5_15.py diff --git a/CLAUDE.md b/CLAUDE.md index eb239a0..dbcf759 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.14.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.15.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/Makefile b/Makefile index 01d2ea0..08ef79a 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.14.dbml +DBML ?= ../../docs/model/data-model-v5.15.dbml .PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index afcb388..62bb0e5 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ email без учёта регистра проверяет только сер | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.14.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.15.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -523,7 +523,7 @@ email без учёта регистра проверяет только сер ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.14.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -546,7 +546,7 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) @@ -619,7 +619,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.14.dbml`](../docs/model/data-model-v5.14.dbml) — модель данных; + [`../docs/model/data-model-v5.15.dbml`](../docs/model/data-model-v5.15.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0003_dbml_v5_15.py b/api/alembic/versions/0003_dbml_v5_15.py new file mode 100644 index 0000000..6b51c48 --- /dev/null +++ b/api/alembic/versions/0003_dbml_v5_15.py @@ -0,0 +1,42 @@ +"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13). + +`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип osv_data_type со значениями v5.14 (строк `osv_entry` +с `data_type = 'NU'` быть не должно; иначе downgrade падает на USING — это осознанно, как в 0002). + +Приведение email к нижнему регистру — данные, не структура: при дублях по регистру `UPDATE` нарушит UNIQUE и миграция +упадёт (осознанно — слияние учёток решает человек); downgrade регистр не возвращает. + +Revision ID: 0003_dbml_v5_15 +Revises: 0002_dbml_v5_14 +Create Date: 2026-10-05 +""" + +from collections.abc import Sequence + +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0003_dbml_v5_15" +down_revision: str | Sequence[str] | None = "0002_dbml_v5_14" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +OSV_DATA_TYPE_V5_14 = ("BU", "CURRENCY", "QUANTITY", "SUM") + + +def upgrade() -> None: + # порядок значений — как в DBML v5.15 (сверка enum'ов сравнивает упорядоченные списки) + op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'") + op.execute(f"UPDATE {SCHEMA}.app_user SET email = lower(email) WHERE email <> lower(email)") + + +def downgrade() -> None: + op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type RENAME TO osv_data_type_old") + postgresql.ENUM(*OSV_DATA_TYPE_V5_14, name="osv_data_type", schema=SCHEMA).create(op.get_bind(), checkfirst=False) + op.execute( + f"ALTER TABLE {SCHEMA}.osv_entry ALTER COLUMN data_type TYPE {SCHEMA}.osv_data_type " + f"USING data_type::text::{SCHEMA}.osv_data_type" + ) + op.execute(f"DROP TYPE {SCHEMA}.osv_data_type_old") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index 2f7cbb6..b198be3 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -2,7 +2,7 @@ Запуск из `api/`: - python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index eeb2926..16da494 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.functions import FunctionElement SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.14.dbml" +DBML_FILE_NAME = "data-model-v5.15.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 43385f1..3ca02bb 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -164,6 +164,7 @@ async def _apply_chart_of_accounts(session: AsyncSession, data: SeedData, stat: async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableStat, link_stat: TableStat) -> None: """Dev-пользователи: upsert `app_user` по `email`, затем `user_role` по (user_id, role_id). + Email из файла нормализуется (`strip().lower()`, DBML v5.15) — и при сверке с БД, и при вставке. `created_at` ставится при вставке, `updated_at` — при вставке и при изменении данных (триггера в БД нет); неизменившиеся строки не трогаются. Назначения ролей только добавляются: роль, убранная из файла, остаётся назначенной (удалений нет). `external_id` не задаётся (SSO-субъект dev-пользователя неизвестен). @@ -175,7 +176,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt } role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} stored = { - row.email: row + (row.email or "").lower(): row for row in ( await session.execute( select( @@ -192,6 +193,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt } user_ids: dict[str, Any] = {} for user in data.users: + email = user.email.strip().lower() values = { "full_name": user.full_name, "employee_no": user.employee_no, @@ -199,17 +201,17 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt "business_segment": user.business_segment, "is_active": user.is_active, } - row = stored.get(user.email) + row = stored.get(email) if row is None: user_stat.inserted += 1 - user_ids[user.email] = uuid.uuid4() + user_ids[email] = uuid.uuid4() await session.execute( pg_insert(AppUser).values( - id=user_ids[user.email], email=user.email, **values, created_at=func.now(), updated_at=func.now() + id=user_ids[email], email=email, **values, created_at=func.now(), updated_at=func.now() ) ) continue - user_ids[user.email] = row.id + user_ids[email] = row.id if any(getattr(row, name) != value for name, value in values.items()): user_stat.updated += 1 await session.execute(update(AppUser).where(AppUser.id == row.id).values(**values, updated_at=func.now())) @@ -219,7 +221,7 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt new_rows = [] for user in data.users: for role_code in user.role_codes: - pair = (user_ids[user.email], role_ids[role_code]) + pair = (user_ids[user.email.strip().lower()], role_ids[role_code]) if pair in existing: link_stat.unchanged += 1 else: diff --git a/api/src/domain/policy.py b/api/src/domain/policy.py index f6b96f9..2ac7619 100644 --- a/api/src/domain/policy.py +++ b/api/src/domain/policy.py @@ -6,7 +6,7 @@ Код роли (`role.code`) здесь не читается. Право (`require`) отвечает «может ли вообще», policy — «какие именно записи»; объект вне видимости вызывающий превращает в 404 с тем же телом, что у несуществующего. -ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.14). +ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.15). """ import uuid diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 520bdda..0fbdc36 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.14.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.15.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.14" +DBML_VERSION = "5.15" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/access.py b/api/src/models/access.py index 9c6f0d4..3bb83ee 100644 --- a/api/src/models/access.py +++ b/api/src/models/access.py @@ -1,4 +1,4 @@ -"""Часть 02 DBML v5.14 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" +"""Часть 02 DBML v5.15 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" import datetime import uuid diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py index 10a69b3..5bc3ed5 100644 --- a/api/src/models/accounting.py +++ b/api/src/models/accounting.py @@ -1,4 +1,4 @@ -"""Часть 03 DBML v5.14 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" +"""Часть 03 DBML v5.15 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" import datetime import uuid diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 4b8ae61..53e3723 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -1,4 +1,4 @@ -"""Часть 09 DBML v5.14 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. +"""Часть 09 DBML v5.15 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. `audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` diff --git a/api/src/models/base.py b/api/src/models/base.py index 6436aed..e5cbbbc 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -6,7 +6,7 @@ from sqlalchemy import MetaData from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column -#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.14). +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.15). SCHEMA = "dkb" #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 1af26eb..3e378f0 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -1,4 +1,4 @@ -"""Часть 07 DBML v5.14 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. +"""Часть 07 DBML v5.15 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из `osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 849ac92..183ea2b 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,4 +1,4 @@ -"""Часть 01 DBML v5.14 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.15 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 0c69eec..464c20d 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ -"""Enum'ы модели данных DBML v5.14 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.15 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.14.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.15.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. @@ -150,6 +150,7 @@ class OsvDataType(enum.StrEnum): """DBML `osv_data_type`.""" BU = "BU" + NU = "NU" CURRENCY = "CURRENCY" QUANTITY = "QUANTITY" SUM = "SUM" diff --git a/api/src/models/files.py b/api/src/models/files.py index 6b2c51a..3241f80 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.14 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.15 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index 3ad377b..1fa8061 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.14 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.15 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index a2011bf..0f007b4 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -1,4 +1,4 @@ -"""Часть 05 DBML v5.14 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). +"""Часть 05 DBML v5.15 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом (`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index 3f6dff5..3f81214 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.14 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.15 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/schemas/admin.py b/api/src/schemas/admin.py index b5e1429..8209028 100644 --- a/api/src/schemas/admin.py +++ b/api/src/schemas/admin.py @@ -1,6 +1,6 @@ """Схемы API администрирования `/admin/*` (spec users-admin, design D-8): пользователи, роли, матрица прав. -Поля — как в DBML v5.14 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом +Поля — как в DBML v5.15 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом входе через РАИСА), поэтому тела запросов запрещают лишние поля. """ diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 4852613..9368c60 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -1,4 +1,4 @@ -"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.14.""" +"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.15.""" import datetime import uuid diff --git a/api/src/services/users_admin.py b/api/src/services/users_admin.py index 3bef7f9..a1acda1 100644 --- a/api/src/services/users_admin.py +++ b/api/src/services/users_admin.py @@ -82,9 +82,9 @@ class UsersAdminService: return user, office, roles[user.id] async def create_user(self, data: UserCreate) -> AppUser: - """Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.""" - email = str(data.email).strip() - if await repo.email_taken(self._session, email.lower()): + """Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится в нижнем.""" + email = str(data.email).strip().lower() + if await repo.email_taken(self._session, email): raise ConflictError("EMAIL_EXISTS", "Пользователь с таким email уже существует") await self._check_office(data.regional_office_id) codes = _unique(data.roles) diff --git a/api/tests/test_api_admin.py b/api/tests/test_api_admin.py index 18356d3..c5ff631 100644 --- a/api/tests/test_api_admin.py +++ b/api/tests/test_api_admin.py @@ -91,7 +91,7 @@ async def test_create_user_with_role_gives_role_permissions_in_me( business_segment="DKB", roles=["KA_RF"], ) - assert created["email"] == "Ivan.Petrov@example.com" # регистр локальной части сохранён (EmailStr: домен строчными) + assert created["email"] == "ivan.petrov@example.com" # v5.15: email хранится в нижнем регистре assert created["roles"] == ["KA_RF"] assert created["is_active"] is True assert created["regional_office"] == { @@ -123,7 +123,7 @@ async def test_create_user_with_role_gives_role_permissions_in_me( assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user_id) assert event.user_id == await _user_id(seeded, ADMIN) assert event.after_value == { - "email": "Ivan.Petrov@example.com", + "email": "ivan.petrov@example.com", "full_name": "Иван Петров", "employee_no": "E-1", "regional_office_id": str(rostov.id), @@ -138,6 +138,23 @@ async def test_create_user_with_role_gives_role_permissions_in_me( assert me.json()["permissions"] == reference["permissions"] +@pytest.mark.integration +async def test_create_user_stores_email_in_lower_case(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """v5.15: `Qa1@Example.com` хранится как `qa1@example.com`; `/me` находит пользователя по любому регистру.""" + admin = await auth_client(ADMIN) + office_id = str((await _office(seeded, ROSTOV)).id) + created = await _create( + admin, email="Qa1@Example.com", full_name="Ку", regional_office_id=office_id, roles=["KA_RF"] + ) + assert created["email"] == "qa1@example.com" + stored = (await seeded.execute(select(AppUser.email).where(AppUser.id == uuid.UUID(created["id"])))).scalar_one() + assert stored == "qa1@example.com" + for variant in ("Qa1@Example.com", "qa1@example.com", "QA1@EXAMPLE.COM"): + response = await (await auth_client(variant)).get("/me") + assert response.status_code == HTTP_OK + assert response.json()["email"] == "qa1@example.com" + + @pytest.mark.integration async def test_create_user_email_in_other_case_conflicts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: admin = await auth_client(ADMIN) diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py index aa12fcd..653848f 100644 --- a/api/tests/test_api_constraint_races.py +++ b/api/tests/test_api_constraint_races.py @@ -138,10 +138,10 @@ async def test_user_email_race( @pytest.mark.integration -async def test_user_email_race_with_other_case_is_not_caught( +async def test_user_email_race_with_other_case_is_caught( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: - """Известное ограничение: DB-ограничение чувствительно к регистру — дубликат в другом регистре гонку не ловит.""" + """v5.15: email хранится строчным, поэтому UNIQUE ловит гонку и с дублем в другом регистре.""" office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one() admin = await auth_client(ADMIN) body = {"email": "Race.Case@example.com", "full_name": "Гонка Регистр", "regional_office_id": str(office.id)} @@ -150,7 +150,9 @@ async def test_user_email_race_with_other_case_is_not_caught( response = await admin.post(USERS, json={**body, "email": "race.case@example.com"}) - assert response.status_code == HTTP_CREATED # индекс по точному значению: дубль в другом регистре проходит + _assert_coded( + response, "EMAIL_EXISTS", "Пользователь с таким email уже существует", "race.case@example.com", "Гонка" + ) @pytest.mark.integration diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 1a38b8b..39c06a2 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.15.dbml" - # шапка 5.15 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.14", "v5.15") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели"]) + newer = tmp_path / "data-model-v5.16.dbml" + # шапка 5.16 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.15", "v5.16") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.15.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.14", "v5.15", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.16.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.15", "v5.16", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели\n" + assert result.stdout == "версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 694e815..1eb65cb 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.14: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.15: имена, значения и их порядок (без БД).""" import enum import re @@ -15,10 +15,15 @@ EXPECTED_ENUM_COUNT = 27 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.14" + assert DBML_VERSION == "5.15" assert f"v{DBML_VERSION}" in dbml_path().name +def test_osv_data_type_has_nu_after_bu() -> None: + """v5.15: налоговый учёт (`NU`) идёт сразу после `BU` — порядок DBML.""" + assert [m.value for m in ENUMS["osv_data_type"]] == ["BU", "NU", "CURRENCY", "QUANTITY", "SUM"] + + def test_enums_count_and_names_match_dbml() -> None: dbml_enums = {e.name for e in load_dbml().enums} assert len(dbml_enums) == EXPECTED_ENUM_COUNT diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index dc3f0ee..505eeb0 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,5 +1,5 @@ -"""Миграции 0001 (DBML v5.13) и 0002 (v5.14) на PostgreSQL 16: полный цикл, откат на шаг, пустой autogenerate, -схема = схеме ORM. +"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15) на PostgreSQL 16: полный цикл, откат на шаг, пустой +autogenerate, схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из @@ -18,11 +18,16 @@ from sqlalchemy import Connection, make_url, text from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from src.models import Base -from src.models.enums import ENUMS +from src.models.enums import ENUMS, OsvDataType from tests.pg_helpers import alembic_async as alembic from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic, service_url from tests.test_models_part_01_03 import _check_related_group_member_uniqueness -from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job +from tests.test_models_part_04_06 import ( + _add_osv_entry, + _check_nu_entry_inserts, + _check_nulls_not_distinct, + _make_file_with_job, +) from tests.test_models_part_07_09 import ( ALL_TABLES, EXPECTED_ENUM_COUNT, @@ -30,7 +35,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0002_dbml_v5_14" +HEAD = "0003_dbml_v5_15" +V5_14 = "0002_dbml_v5_14" PREVIOUS = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" @@ -113,6 +119,22 @@ async def _assert_full_model(conn: AsyncConnection) -> None: assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [HEAD] +async def _insert_user(conn: AsyncConnection, office_id: uuid.UUID, email: str | None) -> uuid.UUID: + """Сырой INSERT пользователя (на ревизии 0002 ORM v5.15 не подходит); NOT NULL — `full_name`, филиал.""" + user_id = uuid.uuid4() + await conn.execute( + text("INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:id, :email, 'Тест', :ro)"), + {"id": user_id, "email": email, "ro": office_id}, + ) + return user_id + + +async def _insert_office(conn: AsyncConnection) -> uuid.UUID: + office_id = uuid.uuid4() + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:id, 'ГО')"), {"id": office_id}) + return office_id + + async def _truncate_model_tables(engine: AsyncEngine) -> None: async with engine.begin() as conn: await conn.execute(text("TRUNCATE " + ", ".join(f"dkb.{table}" for table in sorted(ALL_TABLES)) + " CASCADE")) @@ -154,8 +176,8 @@ async def test_full_cycle_upgrade_downgrade_upgrade(scratch_db_url: str) -> None @pytest.mark.integration -async def test_downgrade_one_step(scratch_db_url: str) -> None: - """Сценарий «Откат на один шаг»: head → 0001 возвращает схему v5.13 (без kind, старый индекс и audit_action).""" +async def test_downgrade_to_v5_13(scratch_db_url: str) -> None: + """Откат head → 0001 (два шага) возвращает схему v5.13 (без kind, старый индекс и audit_action).""" engine = create_async_engine(scratch_db_url) try: await alembic(scratch_db_url, "upgrade", "head") @@ -199,6 +221,73 @@ async def test_downgrade_one_step(scratch_db_url: str) -> None: await engine.dispose() +@pytest.mark.integration +async def test_downgrade_one_step(scratch_db_url: str) -> None: + """Сценарий «Откат на один шаг»: head → 0002 убирает `NU` из `osv_data_type`; регистр email не возвращается.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", "head") + async with engine.begin() as conn: + [labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.osv_data_type)::text[]") + assert labels == ["BU", "NU", "CURRENCY", "QUANTITY", "SUM"] + await _insert_user(conn, await _insert_office(conn), "lower@x.ru") + await engine.dispose() # пул asyncpg кэширует OID типов; downgrade пересоздаёт enum + + await alembic(scratch_db_url, "downgrade", V5_14) + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_14] + [labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.osv_data_type)::text[]") + assert labels == ["BU", "CURRENCY", "QUANTITY", "SUM"] + assert await _scalars(conn, "SELECT email FROM dkb.app_user") == ["lower@x.ru"] + # колонка осталась типизированной новым типом: старые значения читаются, остальные схемы не сломаны + assert await _scalars( + conn, + "SELECT udt_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'osv_entry' AND column_name = 'data_type'", + ) == ["osv_data_type"] + await engine.dispose() + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + await _assert_full_model(conn) + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_downgrade_one_step_fails_while_nu_rows_exist(scratch_db_url: str) -> None: + """Строка `osv_entry` с `data_type = 'NU'` делает откат невозможным (осознанно, как USING в 0002).""" + await alembic(scratch_db_url, "upgrade", "head") + engine = create_async_engine(scratch_db_url) + try: + async with AsyncSession(engine, expire_on_commit=False) as session: + chain = await _make_file_with_job(session) + await _add_osv_entry(session, chain, OsvDataType.NU, "91") + await session.commit() + with pytest.raises(AssertionError): + await alembic(scratch_db_url, "downgrade", V5_14) + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_upgrade_0003_lowercases_existing_emails(scratch_db_url: str) -> None: + """0003 приводит `app_user.email` к нижнему регистру; уже строчные и NULL не затрагиваются.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", V5_14) + async with engine.begin() as conn: + office_id = await _insert_office(conn) + ids = [await _insert_user(conn, office_id, email) for email in ("Email@X.RU", "already@x.ru", None)] + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + rows = (await conn.execute(text("SELECT id, email FROM dkb.app_user"))).all() + assert {tuple(r) for r in rows} == {(ids[0], "email@x.ru"), (ids[1], "already@x.ru"), (ids[2], None)} + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None: """0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы.""" @@ -352,6 +441,7 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None: chain = await _make_file_with_job(session) # замыкает FK-цикл, проверяет server_default await session.commit() await _check_nulls_not_distinct(session, chain) + await _check_nu_entry_inserts(session, chain) # v5.15: osv_data_type содержит NU await _truncate_model_tables(engine) async with AsyncSession(engine, expire_on_commit=False) as session: await _check_active_lock_is_unique(session) @@ -414,11 +504,12 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output - # с двумя ревизиями версия head пишется UPDATE'ом после 0001 — офлайн-SQL доходит до head - assert ( - f"UPDATE dkb.alembic_version SET version_num='{HEAD}' WHERE dkb.alembic_version.version_num = '{PREVIOUS}'" - in output - ) + # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 — офлайн-SQL доходит до head + for new_version, old_version in ((V5_14, PREVIOUS), (HEAD, V5_14)): + assert ( + f"UPDATE dkb.alembic_version SET version_num='{new_version}' " + f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output + ) def test_cluster_commands_use_service_database() -> None: diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index 8100ec0..01467d4 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -1,4 +1,4 @@ -"""ORM частей 01–03 DBML v5.14 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" +"""ORM частей 01–03 DBML v5.15 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" import datetime import uuid diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index 4bd8e9a..e931f0b 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -1,4 +1,4 @@ -"""ORM частей 04–06 DBML v5.14 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL.""" +"""ORM частей 04–06 DBML v5.15 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL.""" import datetime import decimal @@ -18,6 +18,7 @@ from src.models.enums import ( DocumentType, FileStatus, FileType, + OsvDataType, RecognitionPipeline, RecognitionStatus, S3BucketRole, @@ -124,6 +125,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non await session.commit() await _check_nulls_not_distinct(session, chain) await _check_decimal_roundtrip(session, chain) + await _check_nu_entry_inserts(session, chain) finally: await engine.dispose() @@ -195,6 +197,31 @@ async def _check_nulls_not_distinct(session: AsyncSession, chain: Chain) -> None await session.rollback() +async def _add_osv_entry(session: AsyncSession, chain: Chain, data_type: OsvDataType, account: str) -> uuid.UUID: + """Документ ОСВ по счёту `account` и одна строка `osv_entry` с заданным `data_type`; flush без commit.""" + document = _document(chain, DocumentType.OSV, account) + session.add(document) + await session.flush() + entry = OsvEntry( + recognition_job_id=chain.job_id, + document_id=document.id, + owner_legal_entity_id=chain.entity_id, + row_number=1, + data_type=data_type, + ) + session.add(entry) + await session.flush() + return entry.id + + +async def _check_nu_entry_inserts(session: AsyncSession, chain: Chain) -> None: + """v5.15, сценарий «Строка налогового учёта»: `data_type = 'NU'` вставляется и читается обратно.""" + entry_id = await _add_osv_entry(session, chain, OsvDataType.NU, "91") + await session.commit() + stored = (await session.execute(select(OsvEntry.data_type).where(OsvEntry.id == entry_id))).scalar_one() + assert stored is OsvDataType.NU + + async def _check_decimal_roundtrip(session: AsyncSession, chain: Chain) -> None: """Сумма numeric(18,2) читается обратно как `Decimal` без потери точности (деньги — не float).""" document = _document(chain, DocumentType.OSV, "62") diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 49340e3..8a86c5e 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -1,4 +1,4 @@ -"""ORM частей 07–09 DBML v5.14 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" +"""ORM частей 07–09 DBML v5.15 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" import datetime @@ -112,7 +112,7 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None: def test_all_43_tables_registered_and_match_dbml_names() -> None: - """Состав metadata == таблицы DBML v5.14 (имена буквально), всего 43, все в схеме dkb.""" + """Состав metadata == таблицы DBML v5.15 (имена буквально), всего 43, все в схеме dkb.""" tables = list(Base.metadata.sorted_tables) assert len(tables) == EXPECTED_TABLE_COUNT assert all(t.schema == "dkb" for t in tables) diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index 2099ed7..5ac65e6 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -74,6 +74,7 @@ async def test_dev_flag_loads_branches_and_one_user_per_role(db_session: AsyncSe users = (await db_session.scalars(select(AppUser))).all() assert {u.email for u in users} == DEV_EMAILS + assert all(u.email == u.email.lower() for u in users) # v5.15: email в нижнем регистре assert all(u.is_active and u.external_id is None and u.full_name for u in users) assert all(u.business_segment is BusinessSegment.DKB for u in users) assert all(u.created_at is not None and u.updated_at is not None for u in users) @@ -140,6 +141,24 @@ async def test_changed_user_data_updates_row_and_keeps_old_role_link(db_session: assert await _count(db_session, UserRole) == USER_COUNT + 1 +@pytest.mark.integration +async def test_seed_stores_email_in_lower_case_and_stays_idempotent(db_session: AsyncSession, tmp_path: Path) -> None: + """v5.15: email из файла seed хранится как `strip().lower()`; повторный прогон не плодит строк.""" + seed_dir = _copy_seed(tmp_path) + users_file = seed_dir / "dev" / "users.yaml" + text = users_file.read_text(encoding="utf-8") + assert "email: expert@armka.local" in text + users_file.write_text(text.replace("email: expert@armka.local", "email: Expert@Armka.LOCAL"), encoding="utf-8") + + await run_seed(db_session, dev=True, seed_dir=seed_dir) + emails = set((await db_session.scalars(select(AppUser.email))).all()) + assert emails == DEV_EMAILS + + report = await run_seed(db_session, dev=True, seed_dir=seed_dir) + assert (report.tables["app_user"].inserted, report.tables["app_user"].updated) == (0, 0) + assert await _count(db_session, AppUser) == USER_COUNT + + @pytest.mark.integration @pytest.mark.parametrize( ("old", "new", "expected"), diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index f877883..e5f04b2 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -50,9 +50,9 @@ - Миграция (стиль 0002): `upgrade`: `ALTER TYPE dkb.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'`; `UPDATE dkb.app_user SET email = lower(email) WHERE email <> lower(email)`; `downgrade`: пересоздание `osv_data_type` со значениями v5.14 (`RENAME TYPE` → `CREATE TYPE` → `ALTER COLUMN osv_entry.data_type TYPE … USING data_type::text::dkb.osv_data_type` → `DROP TYPE`), email не возвращается. - `OsvDataType`: `BU, NU, CURRENCY, QUANTITY, SUM` (порядок DBML). -- [ ] **Step 1: Тесты.** `test_enums` ожидает `"5.15"` и 5 значений `osv_data_type`; `test_migrations`: `HEAD`, `test_downgrade_one_step` → после `downgrade 0002_dbml_v5_14` `enum_range(NULL::dkb.osv_data_type)` без `NU`; тест данных: вставить `app_user` с `Email@X.RU` на ревизии 0002 → после `upgrade head` email `email@x.ru`; `test_seed_dev`: email dev-пользователей в нижнем регистре; `test_api_admin`: создание с `Qa1@Example.com` → хранится `qa1@example.com`, `/me` по любому регистру. -- [ ] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_seed_dev.py tests/test_api_admin.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003` (+ коммит в docs `docs: entities-v5.15, guide, ссылки на DBML v5.15`). +- [x] **Step 1: Тесты.** `test_enums` ожидает `"5.15"` и 5 значений `osv_data_type`; `test_migrations`: `HEAD`, `test_downgrade_one_step` → после `downgrade 0002_dbml_v5_14` `enum_range(NULL::dkb.osv_data_type)` без `NU`; тест данных: вставить `app_user` с `Email@X.RU` на ревизии 0002 → после `upgrade head` email `email@x.ru`; `test_seed_dev`: email dev-пользователей в нижнем регистре; `test_api_admin`: создание с `Qa1@Example.com` → хранится `qa1@example.com`, `/me` по любому регистру. +- [x] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_seed_dev.py tests/test_api_admin.py -q` → PASS. +- [x] **Step 5: Commit** `feat(model): DBML v5.15 — osv_data_type NU, email в нижнем регистре, миграция 0003` (+ коммит в docs `docs: entities-v5.15, guide, ссылки на DBML v5.15`). --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 11fbbbf..04f1e39 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -6,7 +6,7 @@ ## 1. Модель и инфраструктура -- [ ] 1.1 **DBML v5.15, миграция 0003.** `OsvDataType.NU` (после BU), миграция `0003_dbml_v5_15` (`ALTER TYPE … ADD VALUE 'NU' +- [x] 1.1 **DBML v5.15, миграция 0003.** `OsvDataType.NU` (после BU), миграция `0003_dbml_v5_15` (`ALTER TYPE … ADD VALUE 'NU' AFTER 'BU'`, `UPDATE app_user SET email = lower(email)`; downgrade пересоздаёт enum); `DBML_VERSION = "5.15"`, `Makefile`, `dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр. -- 2.47.2 From 2d69a9e8679cd2c6c915dd87788d94cd28d7585b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:35:41 +0400 Subject: [PATCH 05/35] =?UTF-8?q?chore:=20S3=20get=5Fbytes,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20ObjectStorage,=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B1=D1=80=D0=B8=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2/=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/.env.example | 6 + api/.env.test | 5 + api/pytest.ini | 1 + api/src/core/clients.py | 19 +++ api/src/core/config.py | 7 + api/src/core/storage.py | 23 +++ api/src/services/directory_import.py | 11 +- api/tests/conftest.py | 9 +- api/tests/factories.py | 131 ++++++++++++++- api/tests/test_clients.py | 85 ++++++++++ api/tests/test_config.py | 9 +- api/tests/test_factories.py | 152 +++++++++++++++++- .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 14 files changed, 446 insertions(+), 20 deletions(-) create mode 100644 api/src/core/storage.py create mode 100644 api/tests/test_clients.py diff --git a/api/.env.example b/api/.env.example index 2a5ed79..cae2f29 100644 --- a/api/.env.example +++ b/api/.env.example @@ -57,3 +57,9 @@ OUTBOX_MAX_REPUBLISH=3 # Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413 IMPORT_MAX_BYTES=20971520 + +# Загрузка первичных файлов (ББ/ОФР/ОСВ): предельный размер файла в байтах (50 МБ), больше — ответ 413 +UPLOAD_MAX_BYTES=52428800 +# Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки +MAPPING_POLL_SECONDS=5 +MAPPING_MAX_ATTEMPTS=3 diff --git a/api/.env.test b/api/.env.test index 8f8be01..979d167 100644 --- a/api/.env.test +++ b/api/.env.test @@ -50,3 +50,8 @@ OUTBOX_MAX_REPUBLISH=3 # Импорт справочников: предельный размер файла (20 МБ), как в .env.example IMPORT_MAX_BYTES=20971520 + +# Загрузка первичных файлов и маппинг результатов: значения как в .env.example +UPLOAD_MAX_BYTES=52428800 +MAPPING_POLL_SECONDS=5 +MAPPING_MAX_ATTEMPTS=3 diff --git a/api/pytest.ini b/api/pytest.ini index 12c269f..12d42ea 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -24,3 +24,4 @@ filterwarnings = markers = integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов fixtures: тесты на реальных данных из ../fixtures (внутренний доступ) + e2e: сквозные тесты Среза 2c на реальных данных (NATS + tabular-parser + MinIO + PostgreSQL); запускаются отдельно diff --git a/api/src/core/clients.py b/api/src/core/clients.py index ceb87e5..36dd199 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -5,6 +5,11 @@ from io import BytesIO from miniopy_async import Minio +from miniopy_async.error import S3Error + +from src.core.storage import ObjectNotFoundError + +NO_SUCH_KEY = "NoSuchKey" class S3Storage: @@ -26,3 +31,17 @@ class S3Storage: return response except: raise + + async def get_bytes(self, obj_name: str) -> bytes: + """Содержимое объекта целиком; response закрывается и отпускается; `NoSuchKey` → `ObjectNotFoundError`.""" + try: + response = await self.client.get_object(self.bucket, obj_name) + except S3Error as error: + if error.code == NO_SUCH_KEY: + raise ObjectNotFoundError(obj_name) from error + raise + try: + return await response.read() + finally: + response.close() + await response.release() diff --git a/api/src/core/config.py b/api/src/core/config.py index 69d174d..5f15f84 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -87,6 +87,13 @@ class Settings(BaseSettings): # Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413 IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024 + # Загрузка первичных файлов (Срез 2c): предельный размер файла, больше — 413 + UPLOAD_MAX_BYTES: PositiveInt = 50 * 1024 * 1024 + # Маппинг результата распознавания в `document`/`osv_entry`: период опроса заданий в статусе done/needs_check без + # документов и число попыток (0 и отрицательные значения отвергаются при старте) + MAPPING_POLL_SECONDS: PositiveInt = 5 + MAPPING_MAX_ATTEMPTS: PositiveInt = 3 + @field_validator("CORS_ORIGINS", mode="before") @classmethod def _split_origins(cls, value: Any) -> Any: diff --git a/api/src/core/storage.py b/api/src/core/storage.py new file mode 100644 index 0000000..5ef1544 --- /dev/null +++ b/api/src/core/storage.py @@ -0,0 +1,23 @@ +"""Контракт хранилища объектов (S3), которым пользуются сервисы ядра. + +Реализация — `src.core.clients.S3Storage`, заглушка тестов — `tests.conftest.FakeStorage`. Протокол вынесен из +`services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12). +""" + +from typing import Protocol + + +class ObjectNotFoundError(Exception): + """Объекта с таким ключом нет в бакете (S3 `NoSuchKey`).""" + + +class ObjectStorage(Protocol): + """Что ядру нужно от S3: бакет, запись и чтение объекта целиком.""" + + bucket: str + + async def put_object(self, obj_name: str, file_data: bytes) -> None: ... + + async def get_bytes(self, obj_name: str) -> bytes: + """Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`.""" + ... diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py index cf1c6b8..2cc47b2 100644 --- a/api/src/services/directory_import.py +++ b/api/src/services/directory_import.py @@ -19,7 +19,7 @@ import unicodedata import uuid from collections import defaultdict from pathlib import PurePosixPath -from typing import Any, Protocol +from typing import Any from fastapi import HTTPException, status from loguru import logger @@ -30,6 +30,7 @@ from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import DomainValidationError from src.core.config import Settings +from src.core.storage import ObjectStorage from src.domain.directory_import import ( AddMember, CreateEntity, @@ -74,14 +75,6 @@ MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uui MAX_SUFFIX_LENGTH = 16 -class ObjectStorage(Protocol): - """Что импорту нужно от S3 (`src.core.clients.S3Storage`): бакет и запись объекта.""" - - bucket: str - - async def put_object(self, obj_name: str, file_data: bytes) -> Any: ... - - def check_size(size: int, limit: int) -> None: """413, если файл больше `IMPORT_MAX_BYTES`.""" if size > limit: diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 7687153..dade4ff 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -38,6 +38,7 @@ from src.api.router import build_api_router from src.core.auth import build_verifier from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers +from src.core.storage import ObjectNotFoundError from src.db.db import get_async_session, session_transaction from src.services.session_audit import SessionAudit from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir @@ -205,7 +206,7 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC class FakeStorage: - """Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре.""" + """Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре.""" def __init__(self, bucket: str = "arm-ka") -> None: self.bucket = bucket @@ -214,6 +215,12 @@ class FakeStorage: async def put_object(self, obj_name: str, file_data: bytes) -> None: self.objects[obj_name] = file_data + async def get_bytes(self, obj_name: str) -> bytes: + try: + return self.objects[obj_name] + except KeyError: + raise ObjectNotFoundError(obj_name) from None + @pytest_asyncio.fixture async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: diff --git a/api/tests/factories.py b/api/tests/factories.py index c98b579..5af4174 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -4,6 +4,9 @@ `make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте. - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. +- `make_stored_file`, `make_document`, `make_result_object` — цепочка файла/задания/документа и объект результата в + `FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` создаёт + Task 9 (R-2). - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется `cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом. @@ -11,12 +14,13 @@ import datetime import decimal +import json import secrets import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import delete, func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.models.access import AppUser, Role, UserRole @@ -33,6 +37,7 @@ from src.models.enums import ( EntityType, FileStatus, FileType, + PeriodQuarter, RecognitionPipeline, RecognitionStatus, RelatedGroupKind, @@ -40,8 +45,11 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} +TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) +FACTORY_BUCKET = "arm-ka" def _now() -> datetime.datetime: @@ -159,6 +167,116 @@ async def make_user( # noqa: PLR0913 - сигнатура фабрики фик return user +async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + entity: LegalEntity, + status: FileStatus = FileStatus.UPLOADED, + file_type: FileType = FileType.XLS, + original_name: str = "osv.xls", + content_hash: str | None = None, + job_status: RecognitionStatus = RecognitionStatus.queued, + s3_key: str | None = None, + uploaded_by: uuid.UUID | None = None, +) -> tuple[StoredFile, RecognitionJob]: + """`s3_object` (uploads) + `stored_file` + `recognition_job` (attempt 1), `current_recognition_job_id` = задание. + + Конвейер задания выводится из типа файла (XLS/XLSX/XLSB — tabular, иначе image). Данные остаются в транзакции + сессии (`flush`). + """ + now = _now() + s3 = S3Object( + bucket_role=S3BucketRole.uploads, + bucket=FACTORY_BUCKET, + key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}", + created_at=now, + ) + session.add(s3) + await session.flush() + file = StoredFile( + legal_entity_id=entity.id, + file_type=file_type, + original_name=original_name, + s3_object_id=s3.id, + content_hash=content_hash, + status=status, + uploaded_by=uploaded_by, + uploaded_at=now, + ) + session.add(file) + await session.flush() + pipeline = RecognitionPipeline.tabular if file_type in TABULAR_FILE_TYPES else RecognitionPipeline.image + job = RecognitionJob( + file_id=file.id, + pipeline=pipeline, + request_subject=f"recognition.{pipeline.value}.request", + schema_version="1.0", + status=job_status, + ) + session.add(job) + await session.flush() + file.current_recognition_job_id = job.id + await session.flush() + return file, job + + +async def make_document( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + file: StoredFile, + job: RecognitionJob, + account: str = "60", + year: int = 2025, + quarter: PeriodQuarter = PeriodQuarter.Q1, + status: DocumentStatus = DocumentStatus.RECOGNIZED, + is_cumulative: bool = True, +) -> Document: + """ОСВ-документ по счёту `account` файла `file` (`flush`). + + `job` — задание, породившее документ (у `document` FK на задание нет; параметр держит вызовы единообразными с + `make_stored_file`). + """ + assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах + now = _now() + document = Document( + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.OSV, + osv_account_code=account, + period_year=year, + period_quarter=quarter, + is_cumulative=is_cumulative, + status=status, + created_at=now, + updated_at=now, + ) + session.add(document) + await session.flush() + return document + + +async def make_result_object( + session: AsyncSession, *, job: RecognitionJob, storage: FakeStorage, result: dict[str, Any] +) -> S3Object: + """JSON результата — в `storage` под `results/{file_id}/{job_id}/result.json` + `s3_object` + ссылка в задании.""" + key = f"results/{job.file_id}/{job.id}/result.json" + payload = json.dumps(result, ensure_ascii=False).encode("utf-8") + await storage.put_object(key, payload) + s3 = S3Object( + bucket_role=S3BucketRole.results, + bucket=storage.bucket, + key=key, + content_type="application/json", + size_bytes=len(payload), + created_at=_now(), + ) + session.add(s3) + await session.flush() + job.result_s3_object_id = s3.id + await session.flush() + return s3 + + async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. @@ -320,9 +438,17 @@ class CommittedJobs: self._extra_s3_ids.append(s3_object_id) async def cleanup(self) -> None: - """Удаляет всё созданное в порядке FK (файлы результата, на которые ссылаются задания, — после заданий).""" + """Удаляет всё созданное в порядке FK: строки ОСВ и документы файлов → задания → файлы → объекты S3 и ЮЛ.""" async with self._session_factory() as session, session.begin(): result_s3_ids: list[uuid.UUID] = [] + if self._file_ids: + # задания ссылаются на файл, файл — на текущее задание (use_alter): сначала разрываем обратную ссылку + await session.execute( + update(StoredFile).where(StoredFile.id.in_(self._file_ids)).values(current_recognition_job_id=None) + ) + documents = select(Document.id).where(Document.stored_file_id.in_(self._file_ids)) + await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(documents))) + await session.execute(delete(Document).where(Document.stored_file_id.in_(self._file_ids))) if self.job_ids: rows = await session.execute( select(RecognitionJob.result_s3_object_id).where( @@ -330,6 +456,7 @@ class CommittedJobs: ) ) result_s3_ids = [row[0] for row in rows] + await session.execute(delete(OsvEntry).where(OsvEntry.recognition_job_id.in_(self.job_ids))) await session.execute(delete(RecognitionJob).where(RecognitionJob.id.in_(self.job_ids))) if self._file_ids: await session.execute(delete(StoredFile).where(StoredFile.id.in_(self._file_ids))) diff --git a/api/tests/test_clients.py b/api/tests/test_clients.py new file mode 100644 index 0000000..4f7c6eb --- /dev/null +++ b/api/tests/test_clients.py @@ -0,0 +1,85 @@ +"""`S3Storage.get_bytes` на заглушке Minio (без сети): +чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`.""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from miniopy_async.error import S3Error + +from src.core.clients import S3Storage +from src.core.storage import ObjectNotFoundError + + +class StubResponse: + """Ответ `aiohttp` с флагами закрытия.""" + + def __init__(self, data: bytes, *, fail_read: bool = False) -> None: + self._data = data + self._fail_read = fail_read + self.closed = False + self.released = False + + async def read(self) -> bytes: + if self._fail_read: + raise ConnectionError("обрыв чтения") + return self._data + + def close(self) -> None: + self.closed = True + + async def release(self) -> None: + self.released = True + + +class StubMinio: + def __init__(self, response: StubResponse | None = None, error: Exception | None = None) -> None: + self._response = response + self._error = error + self.calls: list[tuple[str, str]] = [] + + async def get_object(self, bucket: str, obj_name: str) -> StubResponse: + self.calls.append((bucket, obj_name)) + if self._error is not None: + raise self._error + assert self._response is not None # noqa: S101 + return self._response + + +def _s3_error(code: str) -> S3Error: + return S3Error(code, "сообщение", None, None, None, MagicMock()) + + +def _storage(minio: StubMinio) -> S3Storage: + client: Any = minio + return S3Storage(client, "arm-ka") + + +async def test_get_bytes_returns_content_and_closes_response() -> None: + response = StubResponse(b"payload") + minio = StubMinio(response) + + assert await _storage(minio).get_bytes("uploads/a/b.xls") == b"payload" + assert minio.calls == [("arm-ka", "uploads/a/b.xls")] + assert response.closed is True + assert response.released is True + + +async def test_get_bytes_closes_response_when_read_fails() -> None: + response = StubResponse(b"", fail_read=True) + + with pytest.raises(ConnectionError): + await _storage(StubMinio(response)).get_bytes("k") + assert response.closed is True + assert response.released is True + + +async def test_get_bytes_no_such_key_is_object_not_found() -> None: + with pytest.raises(ObjectNotFoundError): + await _storage(StubMinio(error=_s3_error("NoSuchKey"))).get_bytes("missing") + + +async def test_get_bytes_other_s3_error_is_not_swallowed() -> None: + with pytest.raises(S3Error) as caught: + await _storage(StubMinio(error=_s3_error("AccessDenied"))).get_bytes("k") + assert caught.value.code == "AccessDenied" diff --git a/api/tests/test_config.py b/api/tests/test_config.py index 23cc200..c570c8a 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -15,7 +15,10 @@ API_DIR = Path(__file__).resolve().parent.parent def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: """Убирает из окружения процесса переменные, которые перебили бы значения из env-файла.""" for key in list(os.environ): - if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_", "SERVER__")) or key in {"NATS_ENABLED", "DEBUG"}: + if key.startswith(("OPENBAO__", "AUTH_", "OUTBOX_", "ACK_", "SERVER__", "UPLOAD_", "MAPPING_")) or key in { + "NATS_ENABLED", + "DEBUG", + }: monkeypatch.delenv(key) return monkeypatch @@ -54,6 +57,7 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert s.AUTH_EMAIL_CLAIM == "email" assert s.NATS_ENABLED is False assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3) + assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3) assert s.OPENBAO.NATS.NATS_SEED == "" assert s.OPENBAO.NATS.NATS_JWT == "" assert s.OPENBAO.MINIO.MINIO_SECURE is True @@ -90,6 +94,9 @@ REQUIRED_ENV = { ("ACK_TIMEOUT_SECONDS", "0"), ("ACK_TIMEOUT_SECONDS", "-5"), ("OUTBOX_MAX_REPUBLISH", "-1"), + ("UPLOAD_MAX_BYTES", "0"), + ("MAPPING_POLL_SECONDS", "0"), + ("MAPPING_MAX_ATTEMPTS", "0"), ], ) def test_exchange_settings_reject_invalid(clean_env: pytest.MonkeyPatch, key: str, value: str) -> None: diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py index c04d5f5..6b753b6 100644 --- a/api/tests/test_factories.py +++ b/api/tests/test_factories.py @@ -1,19 +1,40 @@ """Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов.""" +import json +from decimal import Decimal + import pytest from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.core.storage import ObjectNotFoundError from src.models.access import UserRole -from src.models.enums import EntityType, RelatedGroupKind -from tests.conftest import AuthClientFactory +from src.models.enums import ( + DocumentStatus, + DocumentType, + EntityType, + FileStatus, + FileType, + PeriodQuarter, + RecognitionPipeline, + RecognitionStatus, + RelatedGroupKind, + S3BucketRole, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( + CommittedJobs, make_company_group, + make_document, make_legal_entity, make_membership, make_regional_office, make_related_group, + make_result_object, + make_stored_file, make_user, ) @@ -110,3 +131,128 @@ async def test_auth_client_sees_data_created_by_factories( response = await client.get("/me") assert response.status_code == HTTP_OK, response.text assert response.json()["regional_office"]["id"] == str(office.id) + + +@pytest.mark.integration +async def test_make_stored_file_builds_file_job_chain(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session) + file, job = await make_stored_file(db_session, entity=entity) + + assert file.legal_entity_id == entity.id + assert file.status == FileStatus.UPLOADED + assert file.file_type == FileType.XLS + assert file.original_name == "osv.xls" + assert file.content_hash is None + assert file.current_recognition_job_id == job.id + assert job.file_id == file.id + assert job.status == RecognitionStatus.queued + assert job.pipeline == RecognitionPipeline.tabular + s3 = await db_session.get(S3Object, file.s3_object_id) + assert s3 is not None + assert s3.bucket_role == S3BucketRole.uploads + assert s3.key.endswith("/osv.xls") + + +@pytest.mark.integration +async def test_make_stored_file_overrides_and_image_pipeline(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session) + office = await make_regional_office(db_session) + user = await make_user(db_session, email="uploader@armka.local", office=office) + file, job = await make_stored_file( + db_session, + entity=entity, + status=FileStatus.PROCESSING, + file_type=FileType.PDF, + original_name="bb.pdf", + content_hash="a" * 64, + job_status=RecognitionStatus.done, + s3_key="uploads/custom/bb.pdf", + uploaded_by=user.id, + ) + + assert (file.status, file.file_type, file.content_hash, file.uploaded_by) == ( + FileStatus.PROCESSING, + FileType.PDF, + "a" * 64, + user.id, + ) + assert job.pipeline == RecognitionPipeline.image + assert job.status == RecognitionStatus.done + s3 = await db_session.get(S3Object, file.s3_object_id) + assert s3 is not None + assert s3.key == "uploads/custom/bb.pdf" + + +@pytest.mark.integration +async def test_make_document_defaults_and_uniqueness(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session) + file, job = await make_stored_file(db_session, entity=entity) + document = await make_document(db_session, file=file, job=job) + other = await make_document(db_session, file=file, job=job, account="62", status=DocumentStatus.NEEDS_CHECK) + + assert document.stored_file_id == file.id + assert document.legal_entity_id == entity.id + assert document.document_type == DocumentType.OSV + assert (document.osv_account_code, document.period_year, document.period_quarter) == ("60", 2025, PeriodQuarter.Q1) + assert document.status == DocumentStatus.RECOGNIZED + assert document.is_cumulative is True + assert other.status == DocumentStatus.NEEDS_CHECK + assert other.id != document.id + + +@pytest.mark.integration +async def test_make_result_object_stores_json_and_links_job(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session) + file, job = await make_stored_file(db_session, entity=entity) + storage = FakeStorage() + result = {"schema_version": "1.0", "значение": "1.50"} + + s3 = await make_result_object(db_session, job=job, storage=storage, result=result) + + key = f"results/{file.id}/{job.id}/result.json" + assert s3.key == key + assert s3.bucket == storage.bucket + assert s3.bucket_role == S3BucketRole.results + assert job.result_s3_object_id == s3.id + assert json.loads(await storage.get_bytes(key)) == result + + +async def test_fake_storage_get_bytes_missing_object() -> None: + storage = FakeStorage() + await storage.put_object("a", b"x") + assert await storage.get_bytes("a") == b"x" + with pytest.raises(ObjectNotFoundError): + await storage.get_bytes("b") + + +@pytest.mark.integration +async def test_committed_jobs_cleanup_removes_documents_and_entries( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + jobs = CommittedJobs(committed_sessionmaker) + job_id = await jobs.make_queued_job() + try: + async with committed_sessionmaker() as session, session.begin(): + job = await session.get(RecognitionJob, job_id) + assert job is not None + file = await session.get(StoredFile, job.file_id) + assert file is not None + file.current_recognition_job_id = job.id + document = await make_document(session, file=file, job=job) + session.add( + OsvEntry( + recognition_job_id=job.id, + document_id=document.id, + owner_legal_entity_id=file.legal_entity_id, + row_number=1, + closing_debit=Decimal("1.00"), + ) + ) + file_id = file.id + finally: + await jobs.cleanup() + + async with committed_sessionmaker() as session: + assert await session.get(StoredFile, file_id) is None + assert not (await session.execute(select(Document).where(Document.stored_file_id == file_id))).all() + assert not (await session.execute(select(OsvEntry).where(OsvEntry.recognition_job_id == job_id))).all() diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index e5f04b2..6241eed 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -82,9 +82,9 @@ async def make_result_object(session, *, job: RecognitionJob, storage: FakeStora # CommittedJobs.cleanup(): перед stored_file удалять osv_entry, document (по file_id) ``` -- [ ] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`. -- [ ] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`. +- [x] **Step 1:** тесты: `get_bytes` возвращает байты и закрывает response (заглушка с флагами), NoSuchKey → `ObjectNotFoundError`; фабрики создают цепочку и `cleanup` удаляет документы. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_factories.py -q`. +- [x] **Step 5: Commit** `chore: S3 get_bytes, протокол ObjectStorage, фабрики файлов/документов, настройки загрузки и маппинга`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 04f1e39..d63b557 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -11,7 +11,7 @@ `dbml_parity.DBML_FILE_NAME`, `HEAD` в `test_migrations`, `test_enums`, `test_check_dbml_script` (фиктивная 5.16); нормализация email в нижний регистр в seed (`_apply_users`) и в `users_admin.create_user/update_user`; тесты seed/admin на регистр. Проверка: `make dbml-check` пуст; `make migration-cycle`. -- [ ] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`; +- [x] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`; `FakeStorage.get_bytes`; `tests/factories.py`: `make_stored_file(session, *, entity, status, content_hash, job_status)`, `make_document`, `make_entries_from_result(session, file, result_json)`; `CommittedJobs.cleanup()` удаляет `osv_entry`, `document`. Настройки `UPLOAD_MAX_BYTES` (50 МиБ), `MAPPING_POLL_SECONDS` (5), `MAPPING_MAX_ATTEMPTS` (3) + `.env.example`/`.env.test`. -- 2.47.2 From be0d1b410ceaf0c2c2a6ef181906292b9492be8d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:50:29 +0400 Subject: [PATCH 06/35] =?UTF-8?q?feat(files):=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=E2=80=94=20S3,=20stored=5Ffile,=20recognition=5Fjob?= =?UTF-8?q?=20=D0=B2=20outbox,=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF,=20=D0=B0?= =?UTF-8?q?=D1=83=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/legal-entities/{id}/files (DOCUMENT_EDIT, видимость ЮЛ → 404): тип по расширению со сверкой сигнатуры (domain/file_types.py), лимит UPLOAD_MAX_BYTES → FILE_TOO_LARGE, дедуп по sha256 в пределах ЮЛ (кроме SUPERSEDED) с рекомендательной блокировкой (ЮЛ, sha256) до конца транзакции запроса, s3_object → put_object → stored_file(UPLOADED) → recognition_job(queued) → current_recognition_job_id, аудит FILE_UPLOAD. Каждый файл — SAVEPOINT: частичный успех (201/422), отказ S3 на файле — STORAGE_UNAVAILABLE только для него; без S3 — 503. safe_filename/CONTENT_TYPES/read_limited вынесены в common/filenames.py (импорт справочников их переиспользует). Схемы UploadResult/FileOut/JobBrief/DocumentBrief, PrimaryFilesService и зависимость для Task 9–10. tasks.md 2.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/files.py | 25 + api/src/api/endpoints/directories/import_.py | 13 +- api/src/api/endpoints/files/__init__.py | 8 + api/src/api/endpoints/files/upload.py | 72 +++ api/src/api/router.py | 9 +- api/src/common/filenames.py | 71 +++ api/src/domain/file_types.py | 74 +++ api/src/schemas/files.py | 93 +++ api/src/services/directory_import.py | 43 +- api/src/services/primary_files.py | 248 ++++++++ api/tests/test_api_directory_import.py | 2 +- api/tests/test_api_files_upload.py | 533 ++++++++++++++++++ api/tests/test_domain_file_types.py | 75 +++ api/tests/test_openapi_routes.py | 2 + .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 16 files changed, 1215 insertions(+), 61 deletions(-) create mode 100644 api/src/api/dependencies/files.py create mode 100644 api/src/api/endpoints/files/__init__.py create mode 100644 api/src/api/endpoints/files/upload.py create mode 100644 api/src/common/filenames.py create mode 100644 api/src/domain/file_types.py create mode 100644 api/src/schemas/files.py create mode 100644 api/src/services/primary_files.py create mode 100644 api/tests/test_api_files_upload.py create mode 100644 api/tests/test_domain_file_types.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py new file mode 100644 index 0000000..6b99064 --- /dev/null +++ b/api/src/api/dependencies/files.py @@ -0,0 +1,25 @@ +"""Зависимости API первичных файлов и документов (Срез 2c; R-6: сессия — только `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends, Request + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.db import SessionDep +from src.api.dependencies.directories import SettingsDep, get_scope +from src.domain.policy import VisibilityScope +from src.services.primary_files import PrimaryFilesService + + +def get_primary_files_service( + request: Request, + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + app_settings: SettingsDep, +) -> PrimaryFilesService: + """Сервис первичных файлов; S3 — `app.state.storage` (`None`, если S3 недоступно или не подключено).""" + return PrimaryFilesService(session, audit, scope, getattr(request.app.state, "storage", None), app_settings) + + +PrimaryFilesServiceDep = Annotated[PrimaryFilesService, Depends(get_primary_files_service)] diff --git a/api/src/api/endpoints/directories/import_.py b/api/src/api/endpoints/directories/import_.py index 3a45b94..0742044 100644 --- a/api/src/api/endpoints/directories/import_.py +++ b/api/src/api/endpoints/directories/import_.py @@ -10,22 +10,13 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile from src.api.dependencies.auth import require from src.api.dependencies.directories import DirectoryImportServiceDep, SettingsDep +from src.common.filenames import read_limited from src.domain.directory_import import OnExisting from src.schemas.directory_import import ImportReportOut -from src.services.directory_import import check_size router = APIRouter() -async def _read_limited(file: UploadFile, limit: int) -> bytes: - """Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`.""" - if file.size is not None: - check_size(file.size, limit) - content = await file.read(limit + 1) - check_size(len(content), limit) - return content - - @router.post( "/import", response_model=ImportReportOut, @@ -40,6 +31,6 @@ async def import_directory( on_existing: Annotated[OnExisting, Form(description="Существующие ГК/ЮЛ: оставить или обновить")] = OnExisting.skip, ) -> ImportReportOut: """Отчёт: счётчики и построчные коды §13.2; при `dry_run = false` — запись в справочник, аудит и файл в S3.""" - content = await _read_limited(file, settings.IMPORT_MAX_BYTES) + content = await read_limited(file, settings.IMPORT_MAX_BYTES) report = await service.run(filename=file.filename or "", content=content, dry_run=dry_run, on_existing=on_existing) return ImportReportOut.model_validate(report.to_dict()) diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py new file mode 100644 index 0000000..9b2223b --- /dev/null +++ b/api/src/api/endpoints/files/__init__.py @@ -0,0 +1,8 @@ +"""Маршруты первичных файлов и документов (specs primary-files, documents-api).""" + +from fastapi import APIRouter + +from src.api.endpoints.files import upload + +router = APIRouter(tags=["files"]) +router.include_router(upload.router) diff --git a/api/src/api/endpoints/files/upload.py b/api/src/api/endpoints/files/upload.py new file mode 100644 index 0000000..7c0e44f --- /dev/null +++ b/api/src/api/endpoints/files/upload.py @@ -0,0 +1,72 @@ +"""`POST /legal-entities/{legal_entity_id}/files` — загрузка файлов организации (spec primary-files «Загрузка файлов +организации», «Дедуп по содержимому»; design D-4). + +multipart: одно или несколько полей `files`; право `DOCUMENT_EDIT`; ЮЛ — в области видимости пользователя, иначе 404. +Тип документа и период не запрашиваются (макет `40`). Каждый файл принимается или отклоняется отдельно (частичный +успех): 201, если принят хотя бы один, иначе 422. Файл больше `UPLOAD_MAX_BYTES` отклоняется кодом `FILE_TOO_LARGE` +без чтения в память целиком; без S3 — 503 `STORAGE_UNAVAILABLE` на весь запрос. +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import PrimaryFilesServiceDep +from src.common.exceptions import DomainError +from src.common.filenames import read_limited, safe_filename +from src.schemas.common import ErrorDetail +from src.schemas.files import AcceptedOut, RejectedOut, UploadResult +from src.services.primary_files import FILE_TOO_LARGE, RejectedFile + +router = APIRouter() + + +@router.post( + "/legal-entities/{legal_entity_id}/files", + operation_id="upload_legal_entity_files", # имя тела multipart в OpenAPI без `__` (test_openapi_routes) + response_model=UploadResult, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require("DOCUMENT_EDIT"))], + responses={ + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"}, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, + }, + summary="Загрузка файлов организации", +) +async def upload_files( + legal_entity_id: uuid.UUID, + files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], + service: PrimaryFilesServiceDep, + response: Response, +) -> UploadResult: + """Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля).""" + entity = await service.get_entity(legal_entity_id) + service.require_storage() + accepted: list[AcceptedOut] = [] + rejected: list[RejectedOut] = [] + for upload in files: + name = upload.filename or "" + try: + content = await read_limited(upload, service.max_upload_bytes) + except HTTPException as error: + if error.status_code != status.HTTP_413_CONTENT_TOO_LARGE: + raise + rejected.append( + RejectedOut(name=safe_filename(name, default=""), code=FILE_TOO_LARGE, message=error.detail) + ) + continue + try: + result = await service.upload_one(entity, name, content) + except DomainError as error: + failure = RejectedFile.from_error(safe_filename(name, default=""), error) + rejected.append( + RejectedOut(name=failure.name, code=failure.code, message=failure.message, file_id=failure.file_id) + ) + continue + accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) + if not accepted: + response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT + return UploadResult(accepted=accepted, rejected=rejected) diff --git a/api/src/api/router.py b/api/src/api/router.py index 5feefb4..79cbf87 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,13 +1,13 @@ """Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). -`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*` и `/admin/*` — всегда, -dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и -подключается в `src/app.py`. +`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы +(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный +`router` собран по настройкам процесса и подключается в `src/app.py`. """ from fastapi import APIRouter -from src.api.endpoints import admin, directories, me +from src.api.endpoints import admin, directories, files, me from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -20,6 +20,7 @@ def build_api_router(settings: Settings) -> APIRouter: api_router.include_router(me.router) api_router.include_router(directories.router) api_router.include_router(admin.router) + api_router.include_router(files.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/common/filenames.py b/api/src/common/filenames.py new file mode 100644 index 0000000..7f53139 --- /dev/null +++ b/api/src/common/filenames.py @@ -0,0 +1,71 @@ +"""Имена и размер загружаемых файлов: общее для импорта справочников и загрузки первичных файлов (Срез 2c, D-4). + +`safe_filename` очищает имя, присланное клиентом, для аудита, БД и ключа S3; `CONTENT_TYPES` — MIME по расширению; +`read_limited` читает загрузку не больше лимита, превышение — `HTTPException` 413 строкой (как в импорте). +""" + +import unicodedata +from pathlib import PurePosixPath + +from fastapi import HTTPException, UploadFile, status + +DEFAULT_FILENAME = "import" +MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/{uuid}/{uuid}/{имя}` остаётся в пределах 1024 байт +MAX_SUFFIX_LENGTH = 16 +MEGABYTE = 1024 * 1024 + +CONTENT_TYPES = { + "csv": "text/csv", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + "pdf": "application/pdf", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", +} + +_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"}) + + +def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> str: + """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. + + Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E, + нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена + `MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта. + Пустое имя (и `.`/`..`) — `default`: импорт подставляет `import`, загрузка первичных файлов — пустую строку + (такой файл отклоняется как `UNSUPPORTED_FILE`). + """ + cleaned = "".join( + char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES + ) + name = PurePosixPath(cleaned).name.strip() + if name in {"", ".", ".."}: + return default + if len(name) > MAX_FILENAME_LENGTH: + suffix = PurePosixPath(name).suffix + suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else "" + name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix + return name + + +def extension(name: str) -> str: + """Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка).""" + return PurePosixPath(name).suffix.lstrip(".").lower() + + +def check_size(size: int, limit: int) -> None: + """413, если файл больше `limit` байт.""" + if size > limit: + limit_text = f"{limit // MEGABYTE} МБ" if limit >= MEGABYTE else f"{limit} байт" + raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit_text}") + + +async def read_limited(file: UploadFile, limit: int) -> bytes: + """Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`.""" + if file.size is not None: + check_size(file.size, limit) + content = await file.read(limit + 1) + check_size(len(content), limit) + return content diff --git a/api/src/domain/file_types.py b/api/src/domain/file_types.py new file mode 100644 index 0000000..eee672f --- /dev/null +++ b/api/src/domain/file_types.py @@ -0,0 +1,74 @@ +"""Тип загружаемого первичного файла (spec primary-files «Загрузка файлов организации»; design D-4). + +Тип определяется по расширению имени и сверяется с сигнатурой содержимого: `xls` — составной документ OLE2 (BIFF8), +`xlsx`/`xlsb` — zip, `pdf`, `png`, `jpg`. Расширение, которого нет в списке, пустое имя или несовпадение сигнатуры — +`UNSUPPORTED_FILE`. Конвейер распознавания: таблицы → `tabular` (парсер, 3 попытки), изображения и PDF → `image` +(ИС Сканирование, 1 попытка — DBML `recognition_job.max_attempts`). +""" + +from dataclasses import dataclass + +from src.common.exceptions import DomainValidationError +from src.common.filenames import CONTENT_TYPES, extension +from src.models.enums import FileType, RecognitionPipeline + +UNSUPPORTED_FILE = "UNSUPPORTED_FILE" + +OLE2_SIGNATURE = b"\xd0\xcf\x11\xe0" +ZIP_SIGNATURE = b"PK\x03\x04" +PDF_SIGNATURE = b"%PDF" +PNG_SIGNATURE = b"\x89PNG" +JPG_SIGNATURE = b"\xff\xd8\xff" + +FILE_TYPES: dict[str, FileType] = { + "xls": FileType.XLS, + "xlsx": FileType.XLSX, + "xlsb": FileType.XLSB, + "pdf": FileType.PDF, + "png": FileType.PNG, + "jpg": FileType.JPG, + "jpeg": FileType.JPG, +} +SIGNATURES: dict[FileType, bytes] = { + FileType.XLS: OLE2_SIGNATURE, + FileType.XLSX: ZIP_SIGNATURE, + FileType.XLSB: ZIP_SIGNATURE, + FileType.PDF: PDF_SIGNATURE, + FileType.PNG: PNG_SIGNATURE, + FileType.JPG: JPG_SIGNATURE, +} +PIPELINES: dict[FileType, RecognitionPipeline] = { + FileType.XLS: RecognitionPipeline.tabular, + FileType.XLSX: RecognitionPipeline.tabular, + FileType.XLSB: RecognitionPipeline.tabular, + FileType.PDF: RecognitionPipeline.image, + FileType.PNG: RecognitionPipeline.image, + FileType.JPG: RecognitionPipeline.image, +} +MAX_ATTEMPTS: dict[RecognitionPipeline, int] = {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1} + +SUPPORTED_TEXT = "xls, xlsx, xlsb, pdf, png, jpg" + + +@dataclass(frozen=True) +class DetectedFile: + """Тип файла, конвейер распознавания и MIME для `s3_object.content_type`.""" + + file_type: FileType + pipeline: RecognitionPipeline + content_type: str + + +def detect_file(name: str, content: bytes) -> DetectedFile: + """Тип по расширению `name` (без учёта регистра), подтверждённый сигнатурой `content`; иначе `UNSUPPORTED_FILE`.""" + suffix = extension(name) + file_type = FILE_TYPES.get(suffix) + if file_type is None: + raise DomainValidationError( + UNSUPPORTED_FILE, f"неподдерживаемый тип файла «{name}»: допустимы {SUPPORTED_TEXT}" + ) + if not content.startswith(SIGNATURES[file_type]): + raise DomainValidationError( + UNSUPPORTED_FILE, f"содержимое файла «{name}» не соответствует расширению .{suffix}" + ) + return DetectedFile(file_type=file_type, pipeline=PIPELINES[file_type], content_type=CONTENT_TYPES[suffix]) diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py new file mode 100644 index 0000000..cb7f59b --- /dev/null +++ b/api/src/schemas/files.py @@ -0,0 +1,93 @@ +"""Схемы API первичных файлов (spec primary-files, documents-api; design D-4, D-11). + +`UploadResult` — ответ загрузки (частичный успех: принятые и отклонённые файлы). `FileOut`/`JobBrief`/`DocumentBrief` — +карточка файла для чтения (Task 9 Среза 2c): статус файла, текущее задание с причиной ошибки и предупреждениями, +документы файла. +""" + +import datetime +import uuid + +from pydantic import BaseModel, ConfigDict + +from src.models.enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + FileStatus, + FileType, + PeriodQuarter, + RecognitionStatus, +) + + +class AcceptedOut(BaseModel): + """Принятый файл: `stored_file.id`, `recognition_job.id` и очищенное имя.""" + + file_id: uuid.UUID + job_id: uuid.UUID + name: str + + +class RejectedOut(BaseModel): + """Отклонённый файл: имя, код (`UNSUPPORTED_FILE`, `FILE_TOO_LARGE`, `DUPLICATE_FILE`, `STORAGE_UNAVAILABLE`), + пояснение; `file_id` — существующий файл при `DUPLICATE_FILE`.""" + + name: str + code: str + message: str + file_id: uuid.UUID | None = None + + +class UploadResult(BaseModel): + """Ответ `POST /legal-entities/{id}/files`: 201, если принят хотя бы один файл, иначе 422.""" + + accepted: list[AcceptedOut] + rejected: list[RejectedOut] + + +class JobBrief(BaseModel): + """Текущее задание распознавания файла: статус, причина ошибки, предупреждения сервиса и маппинга.""" + + id: uuid.UUID + status: RecognitionStatus + error_code: str | None + error_message: str | None + warnings: list[str] + mapping_warnings: list[str] + finished_at: datetime.datetime | None + + +class DocumentBrief(BaseModel): + """Документ файла в карточке файла.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + document_type: DocumentType + osv_account_code: str | None + period_year: int | None + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + status: DocumentStatus + period_check: DocumentCheckResult | None + counterparty_check: DocumentCheckResult | None + locator: str | None + + +class FileOut(BaseModel): + """Карточка файла: поля `stored_file`, текущее задание и документы.""" + + id: uuid.UUID + legal_entity_id: uuid.UUID + original_name: str | None + file_type: FileType + status: FileStatus + version_no: int + size_bytes: int | None + content_hash: str | None + uploaded_at: datetime.datetime | None + uploaded_by: uuid.UUID | None + superseded_by_file_id: uuid.UUID | None + job: JobBrief | None + documents: list[DocumentBrief] diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py index 2cc47b2..2e25d77 100644 --- a/api/src/services/directory_import.py +++ b/api/src/services/directory_import.py @@ -15,13 +15,10 @@ import asyncio import datetime import hashlib -import unicodedata import uuid from collections import defaultdict -from pathlib import PurePosixPath from typing import Any -from fastapi import HTTPException, status from loguru import logger from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import DomainValidationError +from src.common.filenames import CONTENT_TYPES, check_size, extension, safe_filename from src.core.config import Settings from src.core.storage import ObjectStorage from src.domain.directory_import import ( @@ -65,43 +63,6 @@ from src.services.related_groups import CODE_PREFIXES OBJECT_TYPE = "directory_import" IMPORT_DESCRIPTION = "импорт ЕЦП" # `description` событий изменения ГК/ЮЛ, сделанных импортом S3_PREFIX = "uploads/imports" -DEFAULT_FILENAME = "import" -CONTENT_TYPES = { - "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "csv": "text/csv", -} -MEGABYTE = 1024 * 1024 -MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uuid}/{имя}` остаётся в пределах 1024 байт -MAX_SUFFIX_LENGTH = 16 - - -def check_size(size: int, limit: int) -> None: - """413, если файл больше `IMPORT_MAX_BYTES`.""" - if size > limit: - raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ") - - -_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"}) - - -def safe_filename(filename: str | None) -> str: - """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. - - Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E, - нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена - `MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта. - """ - cleaned = "".join( - char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES - ) - name = PurePosixPath(cleaned).name.strip() - if name in {"", ".", ".."}: - return DEFAULT_FILENAME - if len(name) > MAX_FILENAME_LENGTH: - suffix = PurePosixPath(name).suffix - suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else "" - name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix - return name class DirectoryImportService: @@ -267,7 +228,7 @@ class DirectoryImportService: bucket_role=S3BucketRole.uploads, bucket=self._storage.bucket, key=key, - content_type=CONTENT_TYPES.get(PurePosixPath(name).suffix.lstrip(".").lower()), + content_type=CONTENT_TYPES.get(extension(name)), size_bytes=len(content), sha256=hashlib.sha256(content).hexdigest(), created_by=f"user:{self._audit.user_id}", diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py new file mode 100644 index 0000000..9a7f07d --- /dev/null +++ b/api/src/services/primary_files.py @@ -0,0 +1,248 @@ +"""Первичные файлы организации: загрузка (spec primary-files «Загрузка файлов организации», «Дедуп по содержимому»; +audit-trail «События файлов и документов»; design D-4). + +Загрузка одного файла — SAVEPOINT (`begin_nested()`) внутри транзакции запроса (`SessionDep`): отказ файла откатывает +только его записи, принятые раньше файлы того же запроса остаются. Порядок внутри SAVEPOINT: + +1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3; +2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется); +3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся); +4. `stored_file` (`UPLOADED`, `version_no = 1`) и `recognition_job` (`queued`) + `flush`, + `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD`. + +Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи +`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован; +уборка таких объектов — ретеншн бакета (OQ-22). +""" + +import hashlib +import uuid +from dataclasses import dataclass + +from loguru import logger +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from starlette import status as http_status + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainError, NotFoundError +from src.common.filenames import safe_filename +from src.contract import CONTRACT_VERSION, request_subject +from src.core.config import Settings +from src.core.storage import ObjectStorage +from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file +from src.domain.policy import VisibilityScope +from src.models.directories import LegalEntity +from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole +from src.models.files import RecognitionJob, S3Object, StoredFile +from src.repositories import directories as directories_repo +from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND + +OBJECT_TYPE = "stored_file" +UPLOADS_PREFIX = "uploads" +DUPLICATE_FILE = "DUPLICATE_FILE" +FILE_TOO_LARGE = "FILE_TOO_LARGE" +STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" +STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" + + +class StorageUnavailableError(DomainError): + """503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект.""" + + def __init__(self, message: str = STORAGE_UNAVAILABLE_MESSAGE) -> None: + super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message) + + +@dataclass(frozen=True) +class AcceptedFile: + """Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя.""" + + file_id: uuid.UUID + job_id: uuid.UUID + name: str + + +@dataclass(frozen=True) +class RejectedFile: + """Отклонённый файл; `file_id` — существующий файл при `DUPLICATE_FILE`.""" + + name: str + code: str + message: str + file_id: uuid.UUID | None = None + + @classmethod + def from_error(cls, name: str, error: DomainError) -> "RejectedFile": + """Отказ по доменной ошибке загрузки файла `name`.""" + raw_file_id = error.extra.get("file_id") + return cls( + name=name, + code=error.code, + message=error.message, + file_id=uuid.UUID(str(raw_file_id)) if raw_file_id is not None else None, + ) + + +def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str: + """Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей).""" + return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}" + + +class PrimaryFilesService: + """Загрузка первичных файлов ЮЛ от имени пользователя запроса (право `DOCUMENT_EDIT` — на маршруте).""" + + def __init__( + self, + session: AsyncSession, + audit: RequestAudit, + scope: VisibilityScope, + storage: ObjectStorage | None, + settings: Settings, + ) -> None: + self._session = session + self._audit = audit + self._scope = scope + self._storage = storage + self._settings = settings + + @property + def max_upload_bytes(self) -> int: + """Предел размера одного файла (`UPLOAD_MAX_BYTES`).""" + return self._settings.UPLOAD_MAX_BYTES + + async def get_entity(self, entity_id: uuid.UUID) -> LegalEntity: + """ЮЛ в области видимости пользователя; чужое и несуществующее — одинаковый 404 (§10.1).""" + entity = await directories_repo.get_legal_entity(self._session, self._scope, entity_id) + if entity is None: + raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) + return entity + + def require_storage(self) -> ObjectStorage: + """S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`.""" + if self._storage is None: + raise StorageUnavailableError() + return self._storage + + async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None: + """Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому».""" + stmt = ( + select(StoredFile) + .where( + StoredFile.legal_entity_id == entity_id, + StoredFile.content_hash == sha256, + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by(StoredFile.uploaded_at, StoredFile.id) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def upload_one(self, entity: LegalEntity, name: str, content: bytes) -> AcceptedFile: + """Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`, + `STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT.""" + storage = self.require_storage() + safe_name = safe_filename(name, default="") + detected = detect_file(safe_name, content) + sha256 = hashlib.sha256(content).hexdigest() + async with self._session.begin_nested(): + await self._lock_content(entity.id, sha256) + duplicate = await self.find_duplicate(entity.id, sha256) + if duplicate is not None: + raise ConflictError( + DUPLICATE_FILE, + f"файл «{safe_name}» уже загружен для этого ЮЛ", + file_id=str(duplicate.id), + ) + return await self._store( + entity, name=safe_name, content=content, sha256=sha256, storage=storage, detected=detected + ) + + async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None: + """Сериализует загрузки одного содержимого в одно ЮЛ до конца транзакции запроса (двойной клик): второй + запрос дождётся commit первого и увидит дубль. Блокировка снимается с транзакцией, а не с SAVEPOINT.""" + lock_key = func.hashtextextended(f"{OBJECT_TYPE}:{entity_id}:{sha256}", 0) + await self._session.execute(select(func.pg_advisory_xact_lock(lock_key))) + + async def _store( # noqa: PLR0913 - шаги одной загрузки: всё, что уже вычислено в upload_one + self, + entity: LegalEntity, + *, + name: str, + content: bytes, + sha256: str, + storage: ObjectStorage, + detected: DetectedFile, + ) -> AcceptedFile: + now = utc_now() + user_id = self._audit.user_id + file_id, job_id = uuid.uuid4(), uuid.uuid4() + key = upload_key(file_id, job_id, name) + s3_object = S3Object( + id=uuid.uuid4(), + bucket_role=S3BucketRole.uploads, + bucket=storage.bucket, + key=key, + content_type=detected.content_type, + size_bytes=len(content), + sha256=sha256, + created_by=f"user:{user_id}", + created_at=now, + ) + self._session.add(s3_object) + await self._session.flush() + try: + await storage.put_object(key, content) + except Exception as error: + logger.warning( + "S3 не приняло загружаемый файл, файл отклонён. error_category=storage_error, key={key}, error={error}", + key=key, + error=f"{type(error).__name__}: {error}", + ) + raise StorageUnavailableError(f"{STORAGE_UNAVAILABLE_MESSAGE}: файл «{name}» не сохранён") from error + + stored_file = StoredFile( + id=file_id, + legal_entity_id=entity.id, + file_type=detected.file_type, + original_name=name, + s3_object_id=s3_object.id, + content_hash=sha256, + size_bytes=len(content), + status=FileStatus.UPLOADED, + version_no=1, + uploaded_by=user_id, + uploaded_at=now, + ) + job = RecognitionJob( + id=job_id, + file_id=file_id, + pipeline=detected.pipeline, + request_subject=request_subject(detected.pipeline.value), + schema_version=CONTRACT_VERSION, + status=RecognitionStatus.queued, + attempt=1, + max_attempts=MAX_ATTEMPTS[detected.pipeline], + requested_at=now, + ) + # Связей `relationship()` в моделях нет — порядок INSERT задаём сами: файл, затем задание (FK `file_id`); + # FK `current_recognition_job_id` не отложенный (use_alter): ссылку ставим, когда задание уже вставлено + self._session.add(stored_file) + await self._session.flush() + self._session.add(job) + await self._session.flush() + stored_file.current_recognition_job_id = job.id + await self._session.flush() + await self._audit.record( + action=AuditAction.FILE_UPLOAD, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=file_id, + after_value={ + "original_name": name, + "content_hash": sha256, + "job_id": str(job_id), + "file_type": detected.file_type.value, + }, + ) + return AcceptedFile(file_id=file_id, job_id=job_id, name=name) diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py index 8e3bab8..e477184 100644 --- a/api/tests/test_api_directory_import.py +++ b/api/tests/test_api_directory_import.py @@ -18,6 +18,7 @@ from fastapi import FastAPI from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.common.filenames import MAX_FILENAME_LENGTH, safe_filename from src.core.config import get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent @@ -32,7 +33,6 @@ from src.models.directories import ( ) from src.models.enums import AuditAction, AuditSection, EntityType, LeHistoryEvent, RelatedGroupKind, S3BucketRole from src.models.files import S3Object -from src.services.directory_import import MAX_FILENAME_LENGTH, safe_filename from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import make_company_group, make_legal_entity diff --git a/api/tests/test_api_files_upload.py b/api/tests/test_api_files_upload.py new file mode 100644 index 0000000..26e7430 --- /dev/null +++ b/api/tests/test_api_files_upload.py @@ -0,0 +1,533 @@ +"""Загрузка файлов организации (spec primary-files «Загрузка», «Дедуп»; audit-trail «События файлов и документов»). + +Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT): запрос — `begin_nested()`, каждый файл — ещё один +SAVEPOINT внутри него. Реальная ОСВ и параллельный дубль идут на `committed_*` (временная БД с зафиксированным seed, +сессия запроса как в проде): публикатор outbox и второй запрос работают в своих транзакциях и видят только +зафиксированное. +""" + +import asyncio +import hashlib +import json +import uuid +from collections.abc import Sequence +from datetime import timedelta +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.api.dependencies.directories import get_app_settings +from src.core.config import get_settings +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import ( + AuditAction, + AuditSection, + FileStatus, + FileType, + RecognitionPipeline, + RecognitionStatus, + S3BucketRole, +) +from src.models.files import RecognitionJob, S3Object, StoredFile +from src.services.recognition_exchange import publish_pending +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import make_company_group, make_legal_entity +from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats + +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +HTTP_UNAVAILABLE = 503 + +KA_RF = "ka_rf@armka.local" # Ростовский филиал +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал, право DOCUMENT_EDIT есть +CURATOR_GO = "curator_go@armka.local" # права DOCUMENT_EDIT нет +ROSTOV = "Ростовский региональный филиал" + +REAL_OSV = "ОСВ_66 за 2024.xls" +REAL_OSV_PATH = ("osv", "rb", "2024-FY", REAL_OSV) + +BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" +ZIP = b"PK\x03\x04" +PDF = b"%PDF-1.7\n" +XLS_TYPE = "application/vnd.ms-excel" +XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +TINY_LIMIT = 10 # байт: «файл 51 МиБ» без 51 МиБ в памяти теста + +Upload = tuple[str, bytes] + + +def _files_url(entity_id: uuid.UUID) -> str: + return f"/legal-entities/{entity_id}/files" + + +def _xls(tag: str) -> bytes: + """Байты с сигнатурой BIFF8 и уникальным хвостом (разный sha256 — не дубль).""" + return BIFF8 + tag.encode() + + +async def _post(client: httpx.AsyncClient, entity_id: uuid.UUID, *uploads: Upload) -> httpx.Response: + files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] + return await client.post(_files_url(entity_id), files=files) + + +async def _rostov_entity(session: AsyncSession, name: str = "ООО Загрузка") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + return await make_legal_entity(session, name=name, group=group) + + +async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one() + for model in (S3Object, StoredFile, RecognitionJob) + } | { + "file_upload": ( + await session.execute( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD) + ) + ).scalar_one() + } + + +async def _upload_events(session: AsyncSession) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD).order_by(AuditEvent.occurred_at) + return (await session.execute(stmt)).scalars().all() + + +@pytest.fixture +def storage(app: FastAPI) -> FakeStorage: + fake = FakeStorage() + app.state.storage = fake + return fake + + +class FlakyStorage(FakeStorage): + """S3, у которого падает запись объектов с заданным суффиксом ключа.""" + + def __init__(self, failing_suffix: str) -> None: + super().__init__() + self.failing_suffix = failing_suffix + + async def put_object(self, obj_name: str, file_data: bytes) -> None: + if obj_name.endswith(self.failing_suffix): + raise ConnectionError("S3 недоступно") + await super().put_object(obj_name, file_data) + + +class CapturedPublish: + """Делегат `FakeNats`: запоминает тела опубликованных запросов.""" + + def __init__(self) -> None: + self.payloads: list[dict[str, Any]] = [] + + async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: # noqa: ARG002 - протокол издателя + self.payloads.append(json.loads(payload)) + + +# --- «Реальная ОСВ принята и ушла в outbox» ---------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_real_osv_is_accepted_and_published_from_outbox( + fixtures_dir: Any, + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, +) -> None: + content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes() + async with committed_sessionmaker() as session, session.begin(): + entity = await _rostov_entity(session, "АО Русский Бисквит") + user_id = await _user_id(session, KA_RF) + client = await committed_client(KA_RF) + + response = await _post(client, entity.id, (REAL_OSV, content)) + + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert body["rejected"] == [] + (accepted,) = body["accepted"] + file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"]) + assert accepted["name"] == REAL_OSV + key = f"uploads/{file_id}/{job_id}/{REAL_OSV}" + assert committed_storage.objects == {key: content} + + async with committed_sessionmaker() as session: + file = await session.get(StoredFile, file_id) + job = await session.get(RecognitionJob, job_id) + assert file is not None and job is not None + s3 = await session.get(S3Object, file.s3_object_id) + assert s3 is not None + sha256 = hashlib.sha256(content).hexdigest() + assert (file.status, file.file_type, file.content_hash, file.version_no) == ( + FileStatus.UPLOADED, + FileType.XLS, + sha256, + 1, + ) + assert (file.legal_entity_id, file.uploaded_by, file.original_name) == (entity.id, user_id, REAL_OSV) + assert file.current_recognition_job_id == job.id + assert (job.status, job.pipeline, job.max_attempts, job.file_id) == ( + RecognitionStatus.queued, + RecognitionPipeline.tabular, + 3, + file_id, + ) + assert (s3.bucket_role, s3.key, s3.sha256, s3.size_bytes) == (S3BucketRole.uploads, key, sha256, len(content)) + + capture = CapturedPublish() + report = await publish_pending( + committed_sessionmaker, + FakeNats(delegate=capture), + ack_timeout=timedelta(seconds=30), + max_republish=3, + ) + + assert report.published == 1 + (request,) = capture.payloads + REQUEST_VALIDATOR.validate(request) + assert request["s3"] == {"bucket": committed_storage.bucket, "key": key} + assert (request["job_id"], request["file_id"], request["file_type"]) == (str(job_id), str(file_id), "XLS") + assert request["original_name"] == REAL_OSV + + +# --- поля записей, имя файла, аудит ------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_hostile_name_gives_flat_key_and_full_records( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Review Focus 4: кириллица, пробелы, `..` и обратные слэши — ключ S3 без путей, имя без каталогов.""" + entity = await _rostov_entity(seeded) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + content = _xls("hostile") + + response = await _post(client, entity.id, ("../..\\ОСВ 60 (2).xls", content)) + + assert response.status_code == HTTP_CREATED, response.text + (accepted,) = response.json()["accepted"] + file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"]) + assert accepted["name"] == "ОСВ 60 (2).xls" + key = f"uploads/{file_id}/{job_id}/ОСВ 60 (2).xls" + assert list(storage.objects) == [key] + assert ".." not in key + assert "\\" not in key + assert key.count("/") == 3 # noqa: PLR2004 - uploads/{file_id}/{job_id}/{имя} + + file = await seeded.get(StoredFile, file_id) + job = await seeded.get(RecognitionJob, job_id) + assert file is not None and job is not None + s3 = await seeded.get(S3Object, file.s3_object_id) + assert s3 is not None + sha256 = hashlib.sha256(content).hexdigest() + assert file.original_name == "ОСВ 60 (2).xls" + assert (file.status, file.version_no, file.content_hash, file.size_bytes) == ( + FileStatus.UPLOADED, + 1, + sha256, + len(content), + ) + assert file.uploaded_by == user_id + assert file.uploaded_at is not None + assert file.current_recognition_job_id == job_id + assert job.request_subject == "recognition.tabular.request" + assert job.schema_version == "1.0" + assert (job.status, job.attempt, job.max_attempts) == (RecognitionStatus.queued, 1, 3) + assert job.requested_at is not None + assert (s3.bucket_role, s3.bucket, s3.key, s3.content_type) == ( + S3BucketRole.uploads, + storage.bucket, + key, + XLS_TYPE, + ) + assert (s3.sha256, s3.size_bytes, s3.created_by) == (sha256, len(content), f"user:{user_id}") + + +@pytest.mark.integration +async def test_image_file_goes_to_image_pipeline_with_one_attempt( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, entity.id, ("ББ 2024.pdf", PDF + b"scan")) + + assert response.status_code == HTTP_CREATED, response.text + job = await seeded.get(RecognitionJob, uuid.UUID(response.json()["accepted"][0]["job_id"])) + assert job is not None + assert (job.pipeline, job.max_attempts, job.request_subject) == ( + RecognitionPipeline.image, + 1, + "recognition.image.request", + ) + assert len(storage.objects) == 1 + + +@pytest.mark.integration +async def test_two_files_write_two_upload_events( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """audit-trail «Загрузка двух файлов»: два `FILE_UPLOAD` с `object_id` каждого файла.""" + entity = await _rostov_entity(seeded) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + first, second = _xls("first"), ZIP + b"second" + + response = await _post(client, entity.id, ("ОСВ 60.xls", first), ("ОСВ 62.xlsx", second)) + + assert response.status_code == HTTP_CREATED, response.text + accepted = response.json()["accepted"] + assert [item["name"] for item in accepted] == ["ОСВ 60.xls", "ОСВ 62.xlsx"] + events = await _upload_events(seeded) + assert len(events) == len(accepted) + by_object = {event.object_id: event for event in events} + for item, content, file_type in ((accepted[0], first, "XLS"), (accepted[1], second, "XLSX")): + event = by_object[uuid.UUID(item["file_id"])] + assert (event.section, event.object_type, event.user_id) == ( + AuditSection.PRIMARY_DOCUMENTS, + "stored_file", + user_id, + ) + assert event.after_value == { + "original_name": item["name"], + "content_hash": hashlib.sha256(content).hexdigest(), + "job_id": item["job_id"], + "file_type": file_type, + } + assert len(storage.objects) == len(accepted) + + +# --- «Смесь принятых и отклонённых», «Расширение не совпадает с содержимым» --------------------------------------- + + +@pytest.mark.integration +async def test_mixed_request_accepts_valid_and_rejects_others( + app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + app.dependency_overrides[get_app_settings] = lambda: get_settings().model_copy( + update={"UPLOAD_MAX_BYTES": TINY_LIMIT} + ) + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + before = await _counts(seeded) + xlsx = ZIP + b"ok" # 6 байт — в пределах лимита + big = BIFF8 + b"overflow" # больше лимита — как файл 51 МиБ при 50 МиБ + + response = await _post(client, entity.id, ("osv.xlsx", xlsx), ("report.docx", ZIP + b"d"), ("big.xls", big)) + + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert [item["name"] for item in body["accepted"]] == ["osv.xlsx"] + assert {item["name"]: item["code"] for item in body["rejected"]} == { + "report.docx": "UNSUPPORTED_FILE", + "big.xls": "FILE_TOO_LARGE", + } + assert all(item["message"] and item["file_id"] is None for item in body["rejected"]) + after = await _counts(seeded) + assert {name: after[name] - before[name] for name in after} == { + "s3_object": 1, + "stored_file": 1, + "recognition_job": 1, + "file_upload": 1, + } + (key,) = storage.objects + assert key.endswith("/osv.xlsx") + s3 = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one() + assert s3.content_type == XLSX_TYPE + + +@pytest.mark.integration +async def test_signature_mismatch_is_rejected_with_422( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + before = await _counts(seeded) + + response = await _post(client, entity.id, ("osv.xlsx", _xls("biff"))) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["accepted"] == [] + (rejected,) = response.json()["rejected"] + assert (rejected["name"], rejected["code"]) == ("osv.xlsx", "UNSUPPORTED_FILE") + assert await _counts(seeded) == before + assert storage.objects == {} + + +@pytest.mark.integration +async def test_empty_filename_is_unsupported( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Review Focus 4: имя `..` (после очистки — пустое) — `UNSUPPORTED_FILE`, а не файл без имени.""" + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, entity.id, ("..", _xls("noname"))) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + (rejected,) = response.json()["rejected"] + assert (rejected["name"], rejected["code"]) == ("", "UNSUPPORTED_FILE") + assert storage.objects == {} + + +# --- «Чужое ЮЛ», права, S3 -------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_foreign_entity_is_404_like_missing( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + client = await auth_client(CLIENT_MANAGER) + before = await _counts(seeded) + + foreign = await _post(client, entity.id, ("osv.xls", _xls("foreign"))) + missing = await _post(client, uuid.uuid4(), ("osv.xls", _xls("missing"))) + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND, foreign.text + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + assert await _counts(seeded) == before + assert storage.objects == {} + + +@pytest.mark.integration +async def test_upload_requires_document_edit( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + client = await auth_client(CURATOR_GO) + + response = await _post(client, entity.id, ("osv.xls", _xls("curator"))) + + assert response.status_code == HTTP_FORBIDDEN, response.text + assert "DOCUMENT_EDIT" in response.json()["detail"] + assert storage.objects == {} + + +@pytest.mark.integration +async def test_without_storage_is_503(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + entity = await _rostov_entity(seeded) # `app.state.storage` фикстуры `app` — None + client = await auth_client(KA_RF) + before = await _counts(seeded) + + response = await _post(client, entity.id, ("osv.xls", _xls("nostorage"))) + + assert response.status_code == HTTP_UNAVAILABLE, response.text + assert response.json()["detail"]["code"] == "STORAGE_UNAVAILABLE" + assert await _counts(seeded) == before + + +@pytest.mark.integration +async def test_storage_failure_rejects_only_that_file( + app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Отказ S3 на одном файле откатывает только его SAVEPOINT: принятый файл и его записи остаются.""" + storage = FlakyStorage(failing_suffix="/broken.xls") + app.state.storage = storage + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + before = await _counts(seeded) + + response = await _post(client, entity.id, ("good.xls", _xls("good")), ("broken.xls", _xls("broken"))) + + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert [item["name"] for item in body["accepted"]] == ["good.xls"] + (rejected,) = body["rejected"] + assert (rejected["name"], rejected["code"]) == ("broken.xls", "STORAGE_UNAVAILABLE") + after = await _counts(seeded) + assert {name: after[name] - before[name] for name in after} == { + "s3_object": 1, + "stored_file": 1, + "recognition_job": 1, + "file_upload": 1, + } + assert [key.rsplit("/", 1)[1] for key in storage.objects] == ["good.xls"] + + +# --- «Дедуп по содержимому» -------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_duplicate_in_same_entity_is_rejected_other_entity_accepts( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + other = await _rostov_entity(seeded, "ООО Другое") + client = await auth_client(KA_RF) + content = _xls("dup") + first = await _post(client, entity.id, ("ОСВ 60.xls", content)) + assert first.status_code == HTTP_CREATED, first.text + first_id = first.json()["accepted"][0]["file_id"] + before = await _counts(seeded) + + again = await _post(client, entity.id, ("ОСВ 60 копия.xls", content)) + + assert again.status_code == HTTP_UNPROCESSABLE, again.text + (rejected,) = again.json()["rejected"] + assert (rejected["code"], rejected["file_id"], rejected["name"]) == ("DUPLICATE_FILE", first_id, "ОСВ 60 копия.xls") + assert await _counts(seeded) == before + assert len(storage.objects) == 1 + + elsewhere = await _post(client, other.id, ("ОСВ 60.xls", content)) + assert elsewhere.status_code == HTTP_CREATED, elsewhere.text + + +@pytest.mark.integration +async def test_superseded_file_does_not_block_same_content( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + entity = await _rostov_entity(seeded) + client = await auth_client(KA_RF) + content = _xls("superseded") + first = await _post(client, entity.id, ("ОСВ 60.xls", content)) + first_id = uuid.UUID(first.json()["accepted"][0]["file_id"]) + await seeded.execute(update(StoredFile).where(StoredFile.id == first_id).values(status=FileStatus.SUPERSEDED)) + + again = await _post(client, entity.id, ("ОСВ 60.xls", content)) + + assert again.status_code == HTTP_CREATED, again.text + assert again.json()["accepted"][0]["file_id"] != str(first_id) + assert len(storage.objects) == 2 # noqa: PLR2004 - старый и новый объект + + +@pytest.mark.integration +async def test_concurrent_duplicates_accept_only_one( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, +) -> None: + """Двойной клик: два запроса с одним содержимым одновременно — принят один, второй `DUPLICATE_FILE`.""" + async with committed_sessionmaker() as session, session.begin(): + entity = await _rostov_entity(session) + first_client, second_client = await committed_client(KA_RF), await committed_client(KA_RF) + content = _xls("race") + + responses = await asyncio.gather( + _post(first_client, entity.id, ("ОСВ 60.xls", content)), + _post(second_client, entity.id, ("ОСВ 60.xls", content)), + ) + + assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_UNPROCESSABLE] + accepted = next(r for r in responses if r.status_code == HTTP_CREATED).json()["accepted"][0] + rejected = next(r for r in responses if r.status_code == HTTP_UNPROCESSABLE).json()["rejected"][0] + assert (rejected["code"], rejected["file_id"]) == ("DUPLICATE_FILE", accepted["file_id"]) + async with committed_sessionmaker() as session: + files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one() + assert files == 1 + assert len(committed_storage.objects) == 1 diff --git a/api/tests/test_domain_file_types.py b/api/tests/test_domain_file_types.py new file mode 100644 index 0000000..3ab6a92 --- /dev/null +++ b/api/tests/test_domain_file_types.py @@ -0,0 +1,75 @@ +"""Тип загружаемого файла: расширение → `FileType`/pipeline, сверка с сигнатурой (spec primary-files «Загрузка»).""" + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.file_types import MAX_ATTEMPTS, detect_file +from src.models.enums import FileType, RecognitionPipeline + +BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 16 +ZIP = b"PK\x03\x04" + b"\x00" * 16 +PDF = b"%PDF-1.7\n" + b"\x00" * 16 +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 +JPG = b"\xff\xd8\xff\xe0" + b"\x00" * 16 + + +@pytest.mark.parametrize( + ("name", "content", "file_type", "pipeline", "content_type"), + [ + ("ОСВ_66 за 2024.xls", BIFF8, FileType.XLS, RecognitionPipeline.tabular, "application/vnd.ms-excel"), + ( + "osv.xlsx", + ZIP, + FileType.XLSX, + RecognitionPipeline.tabular, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ( + "osv.xlsb", + ZIP, + FileType.XLSB, + RecognitionPipeline.tabular, + "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ), + ("ББ 2024.pdf", PDF, FileType.PDF, RecognitionPipeline.image, "application/pdf"), + ("скан.png", PNG, FileType.PNG, RecognitionPipeline.image, "image/png"), + ("скан.jpg", JPG, FileType.JPG, RecognitionPipeline.image, "image/jpeg"), + ], +) +def test_type_by_extension_and_signature( + name: str, content: bytes, file_type: FileType, pipeline: RecognitionPipeline, content_type: str +) -> None: + detected = detect_file(name, content) + assert detected.file_type is file_type + assert detected.pipeline is pipeline + assert detected.content_type == content_type + + +def test_extension_is_case_insensitive_and_jpeg_is_jpg() -> None: + assert detect_file("ОСВ.XLS", BIFF8).file_type is FileType.XLS + assert detect_file("скан.JPEG", JPG).file_type is FileType.JPG + + +def test_max_attempts_by_pipeline() -> None: + assert MAX_ATTEMPTS == {RecognitionPipeline.tabular: 3, RecognitionPipeline.image: 1} + + +@pytest.mark.parametrize( + ("name", "content"), + [ + ("osv.xlsx", BIFF8), # сценарий спеки «Расширение не совпадает с содержимым» + ("osv.xls", ZIP), + ("scan.pdf", PNG), + ("scan.png", JPG), + ("osv.xls", b""), + ("report.docx", ZIP), # zip, но не таблица ОСВ + ("notes.txt", b"text"), + ("osv", BIFF8), # без расширения + ("", BIFF8), # Review Focus 4: пустое имя + ], +) +def test_unsupported_or_mismatched_file_is_rejected(name: str, content: bytes) -> None: + with pytest.raises(DomainValidationError) as error: + detect_file(name, content) + assert error.value.code == "UNSUPPORTED_FILE" + assert error.value.message diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 56c350f..4e7a940 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -44,6 +44,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{A}/roles": {"get"}, f"{A}/permissions": {"get"}, f"{A}/roles/{{code}}/permissions": {"put"}, + # primary-files (Срез 2c) + "/api/v1/legal-entities/{legal_entity_id}/files": {"post"}, } # Модели, которые обязаны быть в схеме ровно один раз под коротким именем diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 6241eed..bb082c8 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -116,9 +116,9 @@ class PrimaryFilesService: ``` - `schemas/files.py`: `UploadResult{accepted: list[AcceptedOut], rejected: list[RejectedOut]}`, `JobBrief{id, status, error_code, error_message, warnings: list[str], mapping_warnings: list[str], finished_at}`, `DocumentBrief{id, document_type, osv_account_code, period_year, period_quarter, is_cumulative, status, period_check, counterparty_check, locator}`, `FileOut{id, legal_entity_id, original_name, file_type, status, version_no, size_bytes, content_hash, uploaded_at, uploaded_by, superseded_by_file_id, job: JobBrief | None, documents: list[DocumentBrief]}` (используется Task 9). -- [ ] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите. -- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`. -- [ ] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`. +- [x] **Step 1: Тесты.** `test_domain_file_types.py`: 6 типов по сигнатуре, `osv.xlsx` с BIFF8 → `UNSUPPORTED_FILE`, `.docx` → `UNSUPPORTED_FILE`, пустое имя → `UNSUPPORTED_FILE` (Review Focus 4). `test_api_files_upload.py` (integration, `auth_client`, `app.state.storage = FakeStorage()`): сценарии спеки `primary-files` «Реальная ОСВ принята» (байты `../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`; затем `publish_pending` с `FakeNats` из `tests/test_recognition_exchange.py` публикует запрос с ключом `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`), «Смесь» (xlsx + docx + 51 МиБ через `UPLOAD_MAX_BYTES=10` в настройках теста), «Сигнатура», «Чужое ЮЛ» (`client_manager` → 404), «Дубль» (второй раз → `DUPLICATE_FILE` с `file_id`; тот же файл у другого ЮЛ → принят); имя `../..\\ОСВ 60 (2).xls` → ключ без `..` и слэшей; `storage=None` → 503; два `FILE_UPLOAD` в аудите. +- [x] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_api_files_upload.py -q`, `make check`. +- [x] **Step 5: Commit** `feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index d63b557..a153537 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -18,7 +18,7 @@ ## 2. Загрузка и статусы -- [ ] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py` +- [x] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py` (`upload_many`, дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`), `schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт `POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль); -- 2.47.2 From 8d2ced835c9388d679d4a9db91dc6e41d385ef85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 21:55:21 +0400 Subject: [PATCH 07/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20Task=203=20(design=20=E2=84=9621=E2=80=932?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 0293013..32ce749 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -178,3 +178,7 @@ rows без TOTAL, у каждого не-корня есть родитель, | 18 | 2026-10-05 | R-5: ack/результат ставят `PROCESSING` только из `UPLOADED`; `ERROR`/`SUPERSEDED`/`PROCESSED` не перезаписываются | запоздавший ack не воскрешает файл после timeout | параметр `only_from` | | 19 | 2026-10-05 | R-6: `DocumentOut.warnings` — `metrics.mapping_warnings` задания целиком, `group_schema` — по `locator` документа | в DBML нет привязки предупреждения к документу | фильтрация по листу | | 20 | 2026-10-05 | R-7: в Task 1 правится workspace `CLAUDE.md` (вне git): только версия DBML | по плану Task 1 | откат текста | +| 21 | 2026-10-05 | R-8 (Task 3): 503 `STORAGE_UNAVAILABLE` через `DomainError` (`{"detail":{"code","message"}}`), а не строкой `HTTPException`; строка остаётся только у 413 внутри `read_limited` (превращается в `rejected[FILE_TOO_LARGE]`) | спека и Global Constraints плана: ошибки — `DomainError`; бриф противоречил | формат тела 503 | +| 22 | 2026-10-05 | R-9 (Task 3): сбой S3 на одном файле → этот файл в `rejected` с `STORAGE_UNAVAILABLE`, откатывается его SAVEPOINT, принятые остаются; если отклонены все — 422 | модель частичного успеха D-4; клиент видит причину по файлу | вернуть 503 при отказе всех | +| 23 | 2026-10-05 | R-10 (Task 3): загрузка файла — SAVEPOINT внутри транзакции запроса (не отдельная транзакция на файл, как в тексте спеки); недоменная ошибка БД валит весь запрос | указано в брифе плана; атомарность ответа | отдельные сессии на файл | +| 24 | 2026-10-05 | R-11 (Task 3): `pg_advisory_xact_lock(ЮЛ, sha256)` против двойного клика — дубль не проскакивает; `requested_at = now` у задания; `size_bytes` у файла; `.jpeg` → JPG; `operation_id` у маршрута (OpenAPI) | гонка дедупа подтверждена тестом; FIFO outbox идёт по `requested_at` | убрать блокировку | -- 2.47.2 From b7ca7285cc2ab53dc553a8b3b3ca77664fe01af5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:08:40 +0400 Subject: [PATCH 08/35] =?UTF-8?q?feat(exchange):=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D1=8B=20stored=5Ffile=20=D0=BF=D0=BE=20ack/?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5/=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=B0=D1=83=D1=82=D1=83/dlq;=20inbox=20=D0=B1=D0=B5=D0=B7=20Re?= =?UTF-8?q?sultMapper=20(OQ-67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/file_status.py: set_file_status_for_job(session, job, status, *, now, only_from=None) и пакетный set_file_status_for_jobs для вахтёра; переход только при current_recognition_job_id = job.id, PROCESSED и SUPERSEDED не перезаписываются (D-5, R-5). - inbox: ack -> файл UPLOADED->PROCESSING; error -> ERROR; done/needs_check -> UPLOADED->PROCESSING (R-4: ack мог потеряться), из PROCESSING файл ждёт цикла маппинга; inbox не читает S3 и не создаёт документы. - вахтёр: UPDATE recognition_job ... RETURNING id и UPDATE stored_file -> ERROR в той же транзакции; публикатор при dlq -> файл ERROR. - удалены ResultMapper, NoopMapper, _map_result, _MapperError и параметр mapper у handle_message; app.py без mapper. - тесты: статусы файла (ack/error/done/needs_check/без ack/timeout/dlq), чужое задание файл не трогает (Review Focus 1), откат статуса файла вместе с транзакцией сообщения; CommittedJobs.make_file_job/add_job/file. - tasks.md 2.2, шаги Task 4 в плане. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/app.py | 9 +- api/src/services/file_status.py | 85 +++++ api/src/services/recognition_exchange.py | 136 ++++---- api/tests/factories.py | 68 +++- api/tests/test_app.py | 6 +- api/tests/test_exchange_lifespan_nats.py | 7 +- api/tests/test_recognition_exchange.py | 93 +++++- api/tests/test_recognition_inbox.py | 304 +++++++++++++----- .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 10 files changed, 548 insertions(+), 168 deletions(-) create mode 100644 api/src/services/file_status.py diff --git a/api/src/app.py b/api/src/app.py index 83fac09..3d59e9f 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -10,7 +10,7 @@ middleware не подключается), healthcheck (uptime, version, nats: o состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения `recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий -приложения и `NoopMapper` (маппинг результата в доменные таблицы — Срез 2). +приложения (inbox меняет задание и статус файла; маппинг результата в доменные таблицы — отдельный цикл, Срез 2c D-1). Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного логируется и не отменяет остальные. """ @@ -42,7 +42,7 @@ from src.core.config import Settings, get_settings from src.core.exception_handlers import register_exception_handlers from src.core.nats import MessageHandler, NatsConnection from src.db.db import async_session_maker, engine, session_transaction -from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message +from src.services.recognition_exchange import ExchangeTasks, handle_message from src.services.session_audit import SessionAudit settings = get_settings() @@ -102,8 +102,9 @@ async def _connect_s3() -> S3Storage | None: def _result_handler() -> MessageHandler: - """Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения и `NoopMapper` (Срез 0).""" - return partial(handle_message, session_factory=async_session_maker, mapper=NoopMapper()) + """Inbox `recognition.result`: `handle_message` с фабрикой сессий приложения (маппинг результата — отдельный цикл, + Срез 2c design D-1).""" + return partial(handle_message, session_factory=async_session_maker) async def _subscribe_results(nats: NatsConnection) -> None: diff --git a/api/src/services/file_status.py b/api/src/services/file_status.py new file mode 100644 index 0000000..c9b70b4 --- /dev/null +++ b/api/src/services/file_status.py @@ -0,0 +1,85 @@ +"""Статус `stored_file` по заданию распознавания (spec primary-files «Жизненный цикл статуса файла»; design D-5; +рулинги R-4, R-5; docs/context/05-domain-rules.md §9.2). + +Единственное правило перехода: статус файла меняет только его ТЕКУЩЕЕ задание +(`stored_file.current_recognition_job_id = job.id`). Результат, ack, таймаут или dlq старого задания (повторная загрузка +после `SUPERSEDED`, будущий replay) файл не трогает. `PROCESSED` и `SUPERSEDED` — итог маппинга и автозамены — обменом +не перезаписываются. + +Функции выполняют `UPDATE` в транзакции вызывающего (inbox, вахтёр, публикатор) — после изменения строки задания: +порядок блокировок «задание → файл» одинаков у всех участников, взаимоблокировок нет. `audit_event` не пишется: +обмен не аудируется (ASSUMPTION(OQ-55)). +""" + +import uuid +from collections.abc import Collection, Sequence +from datetime import datetime +from typing import Any, Protocol + +from sqlalchemy import CursorResult, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import FileStatus +from src.models.files import StoredFile + +# Итоги маппинга и автозамены: события обмена их не перезаписывают (design D-5) +PROTECTED_FILE_STATUSES: frozenset[FileStatus] = frozenset({FileStatus.PROCESSED, FileStatus.SUPERSEDED}) + + +class JobRef(Protocol): + """Что нужно от задания: `recognition_job.id` и `file_id` (ORM `RecognitionJob` или строка выборки).""" + + @property + def id(self) -> uuid.UUID: ... + + @property + def file_id(self) -> uuid.UUID: ... + + +def _rowcount(result: Any) -> int: + return result.rowcount if isinstance(result, CursorResult) else 0 + + +async def set_file_status_for_job( + session: AsyncSession, + job: JobRef, + status: FileStatus, + *, + now: datetime, + only_from: Collection[FileStatus] | None = None, +) -> bool: + """Перевести файл задания `job` в `status` (`updated_at = now`), если задание — текущее для файла и файл не в + `PROCESSED`/`SUPERSEDED`; `only_from` — допустимые исходные статусы (R-5: ack и результат без ack — только из + `UPLOADED`, запоздавший ack не воскрешает `ERROR`). Возвращает `True`, если строка файла изменена.""" + stmt = ( + update(StoredFile) + .where( + StoredFile.id == job.file_id, + StoredFile.current_recognition_job_id == job.id, + StoredFile.status.not_in(PROTECTED_FILE_STATUSES), + ) + .values(status=status, updated_at=now) + .execution_options(synchronize_session=False) + ) + if only_from is not None: + stmt = stmt.where(StoredFile.status.in_(list(only_from))) + return _rowcount(await session.execute(stmt)) == 1 + + +async def set_file_status_for_jobs( + session: AsyncSession, job_ids: Sequence[uuid.UUID], status: FileStatus, *, now: datetime +) -> int: + """Пакетный вариант для вахтёра: файлы, у которых текущее задание из `job_ids` и статус не `PROCESSED`/ + `SUPERSEDED`, → `status`. Возвращает число изменённых файлов.""" + if not job_ids: + return 0 + stmt = ( + update(StoredFile) + .where( + StoredFile.current_recognition_job_id.in_(list(job_ids)), + StoredFile.status.not_in(PROTECTED_FILE_STATUSES), + ) + .values(status=status, updated_at=now) + .execution_options(synchronize_session=False) + ) + return _rowcount(await session.execute(stmt)) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 8ca7f2e..19934ce 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -4,9 +4,13 @@ docs/context/03-architecture.md, «Контракт обмена через NATS Outbox (задача 23): - `publish_pending()` — один проход публикатора: `queued` → первая публикация → `published`; `published` без ack дольше `ACK_TIMEOUT_SECONDS` → повторная публикация с тем же `Nats-Msg-Id` и тем же `attempt` (счётчик — - `metrics.republish_count`); после `OUTBOX_MAX_REPUBLISH` повторов → `dlq`, `error_code = NO_ACK`. `attempt` не растёт: - обработка не начиналась. Replay (`attempt + 1`) — вне Среза 0. -- `expire_deadlines()` — вахтёр: `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`. + `metrics.republish_count`); после `OUTBOX_MAX_REPUBLISH` повторов → `dlq`, `error_code = NO_ACK`, файл текущего + задания → `ERROR`. `attempt` не растёт: обработка не начиналась. Replay (`attempt + 1`) — вне Среза 2c. + +Статусы `stored_file` (Срез 2c; spec primary-files «Жизненный цикл статуса файла», design D-5, рулинги R-4, R-5) — +только через `services/file_status.py` и только для текущего задания файла (`current_recognition_job_id = job.id`). +- `expire_deadlines()` — вахтёр: `processing`/`published` с `reply_deadline < now` → `timeout`, `error_code = TIMEOUT`; + файлы этих заданий (по `current_recognition_job_id`) → `ERROR` в той же транзакции. - `run_publisher_loop()` / `run_watchdog_loop()` — фоновые циклы; `ExchangeTasks` запускает их в lifespan при `NATS_ENABLED=true` и останавливает до закрытия NATS. @@ -20,22 +24,25 @@ SKIP LOCKED` → публикация → `UPDATE` → `COMMIT`). Блокиро параллельный проход (второй инстанс ядра) пропускает заблокированную строку и не публикует её повторно, а после commit строка уже не подходит под условие выборки. -Обмен не пишет `audit_event` (рулинг R-7, design #20). +Обмен не пишет `audit_event` (рулинг R-7, design #20; ASSUMPTION(OQ-55)). Inbox (задача 24, рулинги R-12, R-22): `handle_message()` — обработчик подписки `recognition.result` (`src/core/nats.py`, queue group `arm-core`). Одно сообщение — одна короткая транзакция без сетевого IO: `SELECT … FOR UPDATE` строки задания (ЖДЁТ блокировку: ack, пришедший, пока публикатор держит строку до своего commit, применяется после него, а не теряется) → проверки статуса → изменения → `COMMIT`. -- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`; повторный ack для `processing` — - тихо игнорируется; ack для другого статуса — warning, задание не меняется. +- ack (`status = processing`): `published` → `processing`, `started_at`, `service_*`, файл `UPLOADED → PROCESSING` + (R-5: только из `UPLOADED`); повторный ack для `processing` — тихо игнорируется; ack для другого статуса — warning, + задание и файл не меняются. - результат (`done | needs_check | error`): дубликат определяется ТОЛЬКО по `recognition_job.result_msg_id == Nats-Msg-Id` (at-least-once) — без изменений; задание в финальном статусе (`FINAL_STATUSES`) — warning, без изменений; иначе (`queued | published | processing`: потерянный ack не блокирует результат) — регистрация файла результата в - `s3_object` (`bucket_role = results`), обновление задания, `ResultMapper.map_result` (Срез 0 — `NoopMapper`; Срез 2 - заменит маппингом результата в `document` / `osv_entry` / `financial_statement_*`) и commit. + `s3_object` (`bucket_role = results`), обновление задания, статус файла и commit. `error` → файл `ERROR`; + `done`/`needs_check` → файл `UPLOADED → PROCESSING` (R-4: ack мог потеряться — core NATS без JetStream), из + `PROCESSING` файл не меняется и ждёт цикла маппинга (design D-1). Inbox НЕ читает файл результата и не создаёт + документы: `ResultMapper` удалён (OQ-67), маппинг — отдельный цикл `services/result_mapping.py`. - Гонка с вахтёром (`expire_deadlines` — один `UPDATE` по статусу) решается блокировкой строки: кто первым взял строку, тот и задаёт финальный статус; второй видит финальный статус и ничего не меняет. -- Невалидное сообщение, неизвестный `job_id`, ошибка БД или mapper'а — лог без повторов; исключения наружу не выходят. +- Невалидное сообщение, неизвестный `job_id`, ошибка БД — лог без повторов; исключения наружу не выходят. Задание без применённого результата дойдёт до `timeout` (осознанный компромисс D-6). """ @@ -49,7 +56,7 @@ from typing import Any, Protocol from loguru import logger from pydantic import ValidationError -from sqlalchemy import CursorResult, and_, case, func, literal, or_, select, update +from sqlalchemy import and_, case, func, literal, or_, select, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -69,8 +76,9 @@ from src.contract import ( ) from src.core.config import Settings from src.core.nats import NatsPublishError -from src.models.enums import RecognitionPipeline, RecognitionStatus, S3BucketRole +from src.models.enums import FileStatus, RecognitionPipeline, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile +from src.services.file_status import set_file_status_for_job, set_file_status_for_jobs WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата») PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50) @@ -220,6 +228,7 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс finished_at=now, ) ) + await set_file_status_for_job(session, row, FileStatus.ERROR, now=now) report.dlq += 1 logger.warning( "Запрос распознавания без ack: задание переведено в dlq. error_category=no_ack, job_id={job_id}, " @@ -348,8 +357,9 @@ async def publish_pending( # noqa: PLR0913 - параметры прохода async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, now: Clock | None = None) -> int: """Вахтёр сроков (spec «Таймаут ожидания результата»): `processing`/`published` с `reply_deadline < now` → - `timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE` с условием по статусу: результат, - успевший перевести задание в финальный статус, не перетирается. Возвращает число заданий.""" + `timeout`, `error_code = TIMEOUT`, `finished_at = now`. Один атомарный `UPDATE … RETURNING id` с условием по + статусу: результат, успевший перевести задание в финальный статус, не перетирается. В той же транзакции файлы этих + заданий (только где задание — текущее, `current_recognition_job_id`) → `ERROR`. Возвращает число заданий.""" moment = (now or _utcnow)() stmt = ( update(RecognitionJob) @@ -363,13 +373,19 @@ async def expire_deadlines(session_factory: async_sessionmaker[AsyncSession], *, error_message="Сервис распознавания не прислал результат до срока ответа (reply_deadline)", finished_at=moment, ) + .returning(RecognitionJob.id) .execution_options(synchronize_session=False) ) async with session_factory() as session, session.begin(): - result = await session.execute(stmt) - count = result.rowcount if isinstance(result, CursorResult) else 0 + job_ids = list((await session.execute(stmt)).scalars()) + files = await set_file_status_for_jobs(session, job_ids, FileStatus.ERROR, now=moment) + count = len(job_ids) if count: - logger.warning("Задания распознавания переведены в timeout по сроку ответа. count={count}", count=count) + logger.warning( + "Задания распознавания переведены в timeout по сроку ответа. count={count}, files_error={files}", + count=count, + files=files, + ) return count @@ -495,31 +511,6 @@ class InboundMessage(Protocol): def subject(self) -> str: ... -class ResultMapper(Protocol): - """Точка расширения inbox (design D-6): маппинг принятого результата в доменные таблицы. - - Вызывается в транзакции обработки сообщения, после обновления задания (`job` уже содержит статус и ссылку на - файл результата и может быть уточнено mapper'ом). Исключение откатывает всю транзакцию: задание не меняется и - дойдёт до `timeout`. Сетевой IO внутри — на совести реализации: транзакция держит блокировку строки задания. - Срез 0 — `NoopMapper`; Срез 2 заменит чтением файла результата и записью `document` / `osv_entry` / … - """ - - async def map_result( - self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage - ) -> None: ... - - -class NoopMapper: - """Mapper Среза 0: результат только регистрируется (`s3_object`, задание), доменные таблицы не заполняются.""" - - async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None: - logger.debug("Маппинг результата не выполняется (NoopMapper, Срез 0). job_id={job_id}", job_id=message.job_id) - - -class _MapperError(Exception): - """Исключение `ResultMapper.map_result` — отличаем от ошибок БД в логе.""" - - def header_msg_id(headers: Mapping[str, str] | None) -> str | None: """Значение заголовка `Nats-Msg-Id` без учёта регистра имени; нет заголовка или пустое значение — `None`.""" if not headers: @@ -588,19 +579,26 @@ async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob return (await session.execute(stmt)).scalar_one_or_none() -def _apply_ack(job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime) -> None: - """Ack (`status = processing`): `published` → `processing`; повтор для `processing` — тихо; иначе — warning.""" +async def _apply_ack( + session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime +) -> None: + """Ack (`status = processing`): `published` → `processing` и файл `UPLOADED → PROCESSING` (R-5); повтор для + `processing` — тихо; иначе — warning (запоздавший ack после `timeout`/`dlq` файл не трогает).""" if job.status is RecognitionStatus.published: job.status = RecognitionStatus.processing job.started_at = now job.service_name = message.service.name.value job.service_version = message.service.version + file_changed = await set_file_status_for_job( + session, job, FileStatus.PROCESSING, now=now, only_from={FileStatus.UPLOADED} + ) logger.info( "Сервис подтвердил получение запроса (ack): задание в processing. job_id={job_id}, service={service}, " - "msg_id={msg_id}", + "msg_id={msg_id}, file_processing={file_changed}", job_id=job.id, service=message.service.name.value, msg_id=msg_id, + file_changed=file_changed, ) elif job.status is RecognitionStatus.processing: logger.debug("Повторный ack для задания в processing, игнорируется. job_id={job_id}", job_id=job.id) @@ -653,7 +651,7 @@ async def _apply_result( session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, msg_id: str, now: datetime ) -> bool: """Результат (`done | needs_check | error`): дубликат и финальный статус — без изменений (`False`); иначе — - регистрация файла результата и обновление задания (`True`, дальше — mapper и commit).""" + регистрация файла результата, обновление задания и статуса файла (`True`, дальше — commit).""" if job.result_msg_id == msg_id: logger.debug( "Повторная доставка результата (дубликат по result_msg_id), изменений нет. job_id={job_id}, " @@ -689,26 +687,26 @@ async def _apply_result( job.result_schema_version = message.result_schema_version job.result_s3_object_id = result_s3_object_id job.result_msg_id = msg_id - return True - - -async def _map_result( - session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage, mapper: ResultMapper -) -> None: - """`mapper.map_result` в транзакции сообщения; его исключение — `_MapperError` (откат всей транзакции).""" - try: - await mapper.map_result(session, job, message) - except Exception as exc: - raise _MapperError(f"ResultMapper.map_result: {type(exc).__name__}") from exc + file_changed = await _apply_result_file_status(session, job, now) logger.info( "Результат распознавания принят. job_id={job_id}, status={status}, service={service}, msg_id={msg_id}, " - "error_code={error_code}", + "error_code={error_code}, file_changed={file_changed}", job_id=job.id, status=job.status.value, service=message.service.name.value, - msg_id=job.result_msg_id, + msg_id=msg_id, error_code=job.error_code, + file_changed=file_changed, ) + return True + + +async def _apply_result_file_status(session: AsyncSession, job: RecognitionJob, now: datetime) -> bool: + """Статус файла по принятому результату (design D-5): `error` → `ERROR`; `done`/`needs_check` → `PROCESSING` только + из `UPLOADED` (R-4: ack мог потеряться), иначе файл ждёт цикла маппинга как есть. Только текущее задание файла.""" + if job.status is RecognitionStatus.error: + return await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) + return await set_file_status_for_job(session, job, FileStatus.PROCESSING, now=now, only_from={FileStatus.UPLOADED}) def _metrics_json(metrics: Metrics) -> dict[str, Any]: @@ -716,7 +714,7 @@ def _metrics_json(metrics: Metrics) -> dict[str, Any]: async def _apply_message( - session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, mapper: ResultMapper, clock: Clock + session: AsyncSession, message: RecognitionResultMessage, header_id: str | None, clock: Clock ) -> None: job = await _lock_job(session, message.job_id) if job is None: @@ -748,36 +746,30 @@ async def _apply_message( ) now = clock() if is_ack: - _apply_ack(job, message, msg_id, now) - elif await _apply_result(session, job, message, msg_id, now): - await _map_result(session, job, message, mapper) + await _apply_ack(session, job, message, msg_id, now) + else: + await _apply_result(session, job, message, msg_id, now) async def handle_message( msg: InboundMessage, *, session_factory: async_sessionmaker[AsyncSession], - mapper: ResultMapper | None = None, now: Clock | None = None, ) -> None: """Обработчик `recognition.result` (spec «Ack сервиса переводит задание в `processing`», «Inbox принимает результат идемпотентно»; design D-6). Модель транзакции и правила — в docstring модуля. - Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД или mapper'а — откат транзакции, - `logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7). + Исключения наружу не выходят: невалидное сообщение — warning; ошибка БД — откат транзакции (задание и файл), + `logger.exception`, без повторов (задание дойдёт до `timeout`). `audit_event` не пишется (R-7, + ASSUMPTION(OQ-55)). Файл результата не читается: маппинг — отдельный цикл (design D-1). """ message = _parse_message(msg) if message is None: return try: async with session_factory() as session, session.begin(): - await _apply_message(session, message, header_msg_id(msg.headers), mapper or NoopMapper(), now or _utcnow) - except _MapperError: - logger.exception( - "Ошибка маппинга результата распознавания, транзакция откачена; задание дойдёт до timeout. " - "error_category=mapper_error, job_id={job_id}", - job_id=message.job_id, - ) + await _apply_message(session, message, header_msg_id(msg.headers), now or _utcnow) except Exception as exc: logger.exception( "Ошибка обработки сообщения {subject}, транзакция откачена (без повторов). error_category=inbox_error, " diff --git a/api/tests/factories.py b/api/tests/factories.py index 5af4174..551dcc2 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -8,7 +8,8 @@ `FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` создаёт Task 9 (R-2). - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые - работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется + работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. `make_file_job` / `add_job` — файл + с текущим заданием и второе задание того же файла (статусы `stored_file` в обмене, Срез 2c). Всё созданное удаляется `cleanup()` в порядке FK, включая строки `s3_object` файлов результата, зарегистрированные inbox'ом. """ @@ -400,6 +401,71 @@ class CommittedJobs: self.job_ids.append(job.id) return job.id + async def make_file_job( + self, + *, + file_status: FileStatus = FileStatus.UPLOADED, + job_status: RecognitionStatus = RecognitionStatus.queued, + **job_fields: Any, + ) -> tuple[uuid.UUID, uuid.UUID]: + """Файл с текущим заданием (`make_stored_file`: `current_recognition_job_id` = задание), зафиксированный в БД. + + `job_fields` — поля `recognition_job` поверх фабрики (`reply_deadline`, `published_at`, `metrics`, …). + Возвращает `(stored_file.id, recognition_job.id)`. + """ + async with self._session_factory() as session, session.begin(): + entity = await make_legal_entity(session, name="ООО Тест статусов файла") + file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=job_status) + for name, value in job_fields.items(): + setattr(job, name, value) + await session.flush() + self._entity_ids.append(entity.id) + self._s3_ids.append(file.s3_object_id) + self._file_ids.append(file.id) + self.job_ids.append(job.id) + return file.id, job.id + + async def add_job( + self, + file_id: uuid.UUID, + *, + status: RecognitionStatus = RecognitionStatus.queued, + current: bool = True, + **job_fields: Any, + ) -> uuid.UUID: + """Ещё одно задание файла `file_id` (как replay: `attempt` = максимум по файлу + 1); `current=True` — оно + становится текущим заданием файла.""" + async with self._session_factory() as session, session.begin(): + file = await session.get(StoredFile, file_id) + assert file is not None + last = ( + await session.execute(select(func.max(RecognitionJob.attempt)).where(RecognitionJob.file_id == file_id)) + ).scalar_one() + job_fields.setdefault("attempt", (last or 0) + 1) + tabular = file.file_type in TABULAR_FILE_TYPES + pipeline = RecognitionPipeline.tabular if tabular else RecognitionPipeline.image + job = RecognitionJob( + file_id=file_id, + pipeline=pipeline, + request_subject=f"recognition.{pipeline.value}.request", + schema_version="1.0", + status=status, + **job_fields, + ) + session.add(job) + await session.flush() + if current: + file.current_recognition_job_id = job.id + self.job_ids.append(job.id) + return job.id + + async def file(self, file_id: uuid.UUID) -> StoredFile: + """Свежее состояние файла из БД (новая сессия).""" + async with self._session_factory() as session: + file = await session.get(StoredFile, file_id) + assert file is not None + return file + async def s3_key(self, job_id: uuid.UUID) -> str: """Ключ S3 исходного файла задания.""" async with self._session_factory() as session: diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 842dbcc..63bb893 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -226,7 +226,8 @@ def test_create_nats_enabled_returns_connection_for_results(monkeypatch: pytest. async def test_result_subscription_uses_inbox_handler_with_dependencies(): - """Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения и `NoopMapper`.""" + """Подписка `recognition.result` получает `handle_message` с фабрикой сессий приложения (без `mapper`: маппинг + результата — отдельный цикл, Срез 2c D-1).""" subscribed: list[Any] = [] class _FakeConnection: @@ -238,9 +239,8 @@ async def test_result_subscription_uses_inbox_handler_with_dependencies(): (handler,) = subscribed assert handler.func is recognition_exchange.handle_message assert handler.args == () - assert set(handler.keywords) == {"session_factory", "mapper"} + assert set(handler.keywords) == {"session_factory"} assert handler.keywords["session_factory"] is app_module.async_session_maker - assert isinstance(handler.keywords["mapper"], recognition_exchange.NoopMapper) def test_create_nats_subscribes_results_on_connect(monkeypatch: pytest.MonkeyPatch): diff --git a/api/tests/test_exchange_lifespan_nats.py b/api/tests/test_exchange_lifespan_nats.py index 133a365..af7e739 100644 --- a/api/tests/test_exchange_lifespan_nats.py +++ b/api/tests/test_exchange_lifespan_nats.py @@ -32,7 +32,7 @@ from src.core.config import get_settings from src.core.nats import RESULT_QUEUE_GROUP, NatsConnection from src.models.enums import RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object -from src.services.recognition_exchange import NoopMapper, handle_message +from src.services.recognition_exchange import handle_message from tests.factories import CommittedJobs from tests.test_recognition_inbox import MESSAGE_VALIDATOR, envelope @@ -277,10 +277,7 @@ async def test_two_core_instances_handle_result_once( """Два `NatsConnection` в queue group `arm-core` на одном subject: результат обрабатывает ровно один инстанс, задание меняется один раз; повторная доставка того же сообщения ничего не меняет.""" settings = get_settings().OPENBAO.NATS - handlers = [ - _CountingHandler(partial(handle_message, session_factory=session_factory, mapper=NoopMapper())) - for _ in range(2) - ] + handlers = [_CountingHandler(partial(handle_message, session_factory=session_factory)) for _ in range(2)] def subscriber(handler: _CountingHandler) -> Callable[[NatsConnection], Awaitable[None]]: async def subscribe(conn: NatsConnection) -> None: diff --git a/api/tests/test_recognition_exchange.py b/api/tests/test_recognition_exchange.py index 1deb48e..8af29a9 100644 --- a/api/tests/test_recognition_exchange.py +++ b/api/tests/test_recognition_exchange.py @@ -1,5 +1,7 @@ """Outbox-публикатор и вахтёр сроков `recognition_job` (spec recognition-job-exchange: «Outbox публикует задания из -`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6). +`queued`», «Запрос без ack публикуется повторно, затем — `dlq`», «Таймаут ожидания результата»; design D-6). Срез 2c: +`timeout` вахтёра и `dlq` публикатора переводят файл текущего задания в `ERROR` (spec primary-files «Жизненный цикл +статуса файла», design D-5). Интеграционные тесты работают против реального PostgreSQL (БД сессии pytest) и NATS из `.env.test`. Публикатор и вахтёр ведут собственные транзакции, поэтому данные фиксируются реально (`CommittedJobs`) и удаляются в teardown. @@ -28,7 +30,7 @@ from src.contract import NATS_MSG_ID_HEADER, RecognitionRequest from src.contract.request import FileType as ContractFileType from src.core.config import get_settings from src.core.nats import NatsConnection, NatsPublishError -from src.models.enums import FileType, RecognitionPipeline, RecognitionStatus +from src.models.enums import FileStatus, FileType, RecognitionPipeline, RecognitionStatus from src.models.files import RecognitionJob from src.services import recognition_exchange as rx from src.services.recognition_exchange import ( @@ -591,6 +593,93 @@ async def test_expire_deadlines( assert job.finished_at is None +@pytest.mark.integration +@pytest.mark.parametrize("file_status", [FileStatus.PROCESSING, FileStatus.UPLOADED]) +@pytest.mark.parametrize("job_status", [RecognitionStatus.processing, RecognitionStatus.published]) +async def test_expire_deadlines_moves_current_file_to_error( + session_factory: async_sessionmaker[AsyncSession], + jobs: CommittedJobs, + file_status: FileStatus, + job_status: RecognitionStatus, +): + """Сценарий «Сервис не ответил» (primary-files): задание — `timeout`, файл — `ERROR` в той же транзакции.""" + clock = Clock() + file_id, job_id = await jobs.make_file_job( + file_status=file_status, job_status=job_status, reply_deadline=clock() - timedelta(minutes=1) + ) + + assert await expire_deadlines(session_factory, now=clock) == 1 + + job, file = await jobs.get(job_id), await jobs.file(file_id) + assert (job.status, job.error_code) == (RecognitionStatus.timeout, "TIMEOUT") + assert (file.status, file.updated_at) == (FileStatus.ERROR, clock()) + + +@pytest.mark.integration +@pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED]) +async def test_expire_deadlines_keeps_processed_and_superseded_file( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, file_status: FileStatus +): + clock = Clock() + file_id, job_id = await jobs.make_file_job( + file_status=file_status, job_status=RecognitionStatus.processing, reply_deadline=clock() - timedelta(minutes=1) + ) + + assert await expire_deadlines(session_factory, now=clock) == 1 + + assert (await jobs.get(job_id)).status is RecognitionStatus.timeout + file = await jobs.file(file_id) + assert (file.status, file.updated_at) == (file_status, None) + + +@pytest.mark.integration +async def test_expire_deadlines_of_non_current_job_keeps_file( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs +): + """Review Focus 1: таймаут старого задания файла, у которого уже новое текущее задание, файл не трогает.""" + clock = Clock() + file_id, old_job = await jobs.make_file_job( + file_status=FileStatus.UPLOADED, + job_status=RecognitionStatus.processing, + reply_deadline=clock() - timedelta(minutes=1), + ) + new_job = await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True) + + assert await expire_deadlines(session_factory, now=clock) == 1 + + assert (await jobs.get(old_job)).status is RecognitionStatus.timeout + file = await jobs.file(file_id) + assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None) + + +@pytest.mark.integration +@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"]) +async def test_dlq_moves_current_file_to_error( + session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, current: bool +): + """Публикатор переводит задание в `dlq` — файл текущего задания `ERROR`; файл чужого задания не меняется.""" + clock = Clock() + file_id, job_id = await jobs.make_file_job( + job_status=RecognitionStatus.published, + published_at=clock() - ACK * 2, + request_msg_id="dlq-test:1", + reply_deadline=clock() + timedelta(hours=1), + metrics={"republish_count": MAX_REPUBLISH}, + ) + # новое задание — `processing`, чтобы публикатор его не трогал (в проходе только задание старого) + new_job = None if current else await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True) + + report = await _publish(session_factory, FakeNats(), clock) + + assert report.dlq == 1 + assert (await jobs.get(job_id)).status is RecognitionStatus.dlq + file = await jobs.file(file_id) + if current: + assert (file.status, file.updated_at) == (FileStatus.ERROR, clock()) + else: + assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None) + + # --- фоновые циклы ----------------------------------------------------------------------------------------------- diff --git a/api/tests/test_recognition_inbox.py b/api/tests/test_recognition_inbox.py index e3d69c3..5a1bec3 100644 --- a/api/tests/test_recognition_inbox.py +++ b/api/tests/test_recognition_inbox.py @@ -1,5 +1,7 @@ """Inbox обмена `recognition_job`: `handle_message()` (spec recognition-job-exchange: «Ack сервиса переводит задание в -`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22). +`processing`», «Inbox принимает результат идемпотентно»; design D-6; рулинги R-7, R-12, R-22). Срез 2c: статусы +`stored_file` по ack / результату (spec primary-files «Жизненный цикл статуса файла», design D-5, рулинги R-4, R-5); +`ResultMapper` удалён — inbox не читает S3 и не создаёт документы (маппинг — отдельный цикл, D-1). Быстрые тесты (без маркера) — разбор заголовка `Nats-Msg-Id`, слияние метрик, отказ невалидным сообщениям без обращения к БД, ошибка БД не выходит наружу, шаблонный `nats_callback` удалён. @@ -10,6 +12,7 @@ import asyncio import importlib.util +import inspect import json import time import uuid @@ -29,14 +32,14 @@ from nats.aio.msg import Msg from sqlalchemy import JSON, func, select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine -from src.contract import NATS_MSG_ID_HEADER, RecognitionResultMessage, ack_msg_id, result_msg_id +from src.contract import NATS_MSG_ID_HEADER, ack_msg_id, result_msg_id from src.core.config import get_settings from src.core.nats import NatsConnection -from src.models.enums import RecognitionStatus, S3BucketRole -from src.models.files import RecognitionJob, S3Object +from src.models.enums import FileStatus, RecognitionStatus, S3BucketRole +from src.models.files import RecognitionJob, S3Object, StoredFile +from src.services import recognition_exchange as rx from src.services.recognition_exchange import ( FINAL_STATUSES, - NoopMapper, expire_deadlines, handle_message, header_msg_id, @@ -54,6 +57,7 @@ BUCKET = "arm-ka" READY_TIMEOUT = 5.0 RACE_JOBS = 10 LOCK_HOLD_SECONDS = 0.5 +_ERROR = {"code": "UNSUPPORTED_FORMAT", "message": "формат файла не поддерживается"} # --- инфраструктура --------------------------------------------------------------------------------------------- @@ -103,21 +107,20 @@ def fake_msg(body: Any, msg_id: str | None, *, header: str = NATS_MSG_ID_HEADER, return SimpleNamespace(data=data, headers=headers, subject="recognition.result") -class RecordingMapper: - """Подставной mapper: запоминает вызовы и видимое состояние задания; `fail=True` — исключение; `delay` — пауза - внутри транзакции (строка задания остаётся заблокированной).""" +class SlowFileStatus: + """Подмена `set_file_status_for_job` в inbox: запоминает вызовы (id задания, статус) и держит паузу `delay` + ВНУТРИ транзакции сообщения (строка задания остаётся заблокированной), затем вызывает настоящую функцию.""" - def __init__(self, *, fail: bool = False, delay: float = 0) -> None: - self.fail = fail + def __init__(self, monkeypatch: pytest.MonkeyPatch, *, delay: float) -> None: self.delay = delay - self.calls: list[tuple[uuid.UUID, RecognitionStatus, RecognitionResultMessage]] = [] + self.calls: list[tuple[uuid.UUID, FileStatus]] = [] + self._original = rx.set_file_status_for_job + monkeypatch.setattr(rx, "set_file_status_for_job", self) - async def map_result(self, session: AsyncSession, job: RecognitionJob, message: RecognitionResultMessage) -> None: - self.calls.append((job.id, job.status, message)) - if self.delay: - await asyncio.sleep(self.delay) - if self.fail: - raise RuntimeError("подставная ошибка маппинга") + async def __call__(self, session: AsyncSession, job: RecognitionJob, status: FileStatus, **kwargs: Any) -> bool: + self.calls.append((job.id, status)) + await asyncio.sleep(self.delay) + return await self._original(session, job, status, **kwargs) @pytest.fixture @@ -152,6 +155,13 @@ async def _s3_total(session_factory: async_sessionmaker[AsyncSession]) -> int: return int((await session.execute(select(func.count()).select_from(S3Object))).scalar_one()) +async def _file_row(session_factory: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> dict[str, Any]: + """Снимок всех колонок строки `stored_file`.""" + async with session_factory() as session: + row = (await session.execute(select(StoredFile.__table__).where(StoredFile.id == file_id))).one() + return dict(row._mapping) + + async def _file_id(jobs: CommittedJobs, job_id: uuid.UUID) -> uuid.UUID: return (await jobs.get(job_id)).file_id @@ -264,9 +274,11 @@ async def test_database_error_is_logged_and_not_raised(logs: list[Any]): assert record["exception"] is not None -async def test_noop_mapper_does_nothing(): - message = RecognitionResultMessage.model_validate(envelope(_JOB, _FILE, "done")) - assert await NoopMapper().map_result(None, None, message) is None # type: ignore[arg-type] +def test_inbox_has_no_result_mapper(): + """OQ-67 (Срез 2c, D-1): точки расширения `ResultMapper` / `NoopMapper` нет, `handle_message` без `mapper`.""" + assert not hasattr(rx, "ResultMapper") + assert not hasattr(rx, "NoopMapper") + assert "mapper" not in inspect.signature(handle_message).parameters def test_final_statuses(): @@ -422,22 +434,23 @@ async def test_result_metrics_merge_over_null_metrics( @pytest.mark.integration async def test_redelivery_changes_nothing(session_factory, jobs: CommittedJobs, logs: list[Any]): - """Сценарий «Повторная доставка»: ни recognition_job, ни s3_object не меняются; mapper не вызывается.""" - job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) - file_id = await _file_id(jobs, job_id) + """Сценарий «Повторная доставка»: ни recognition_job, ни s3_object, ни stored_file не меняются.""" + file_id, job_id = await jobs.make_file_job( + file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing + ) msg = fake_msg(envelope(job_id, file_id, "done", metrics={"rows": 3}), result_msg_id(job_id, 1)) - mapper = RecordingMapper() - await _handle(session_factory, msg, mapper=mapper) + await _handle(session_factory, msg) job_before = await _row(session_factory, job_id) + file_before = await _file_row(session_factory, file_id) s3_before = await _s3_rows(session_factory, _result_key(file_id, job_id)) total_before = await _s3_total(session_factory) - await _handle(session_factory, msg, mapper=mapper, now=lambda: datetime.now(UTC) + timedelta(hours=1)) + await _handle(session_factory, msg, now=lambda: datetime.now(UTC) + timedelta(hours=1)) assert await _row(session_factory, job_id) == job_before assert await _s3_rows(session_factory, _result_key(file_id, job_id)) == s3_before assert await _s3_total(session_factory) == total_before - assert len(mapper.calls) == 1 + assert await _file_row(session_factory, file_id) == file_before assert _logged(logs, "дубликат", "DEBUG") assert not _logged(logs, "финальном статусе") @@ -482,13 +495,11 @@ async def test_result_for_final_status_is_logged_and_ignored( file_id = await _file_id(jobs, job_id) before = await _row(session_factory, job_id) total_before = await _s3_total(session_factory) - mapper = RecordingMapper() - await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper) + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1))) assert await _row(session_factory, job_id) == before assert await _s3_total(session_factory) == total_before - assert mapper.calls == [] (record,) = _logged(logs, "финальном статусе", "WARNING") assert status.value in record["message"] @@ -587,39 +598,163 @@ async def test_existing_s3_object_is_reused(session_factory, jobs: CommittedJobs assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1 -# --- mapper ------------------------------------------------------------------------------------------------------ +# --- статусы stored_file (Срез 2c: spec primary-files «Жизненный цикл статуса файла», D-5, R-4, R-5) ------------- @pytest.mark.integration -async def test_mapper_sees_job_and_message(session_factory, jobs: CommittedJobs): - job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) - file_id = await _file_id(jobs, job_id) - mapper = RecordingMapper() +async def test_ack_moves_uploaded_file_to_processing(session_factory, jobs: CommittedJobs): + """Сценарий «Сервис подтвердил»: файл текущего задания — `PROCESSING`, `updated_at` — момент ack.""" + file_id, job_id = await jobs.make_file_job(job_status=RecognitionStatus.published) + moment = datetime(2026, 10, 5, 9, 0, tzinfo=UTC) - await _handle(session_factory, fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), mapper=mapper) + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)), now=lambda: moment + ) - ((seen_job_id, seen_status, message),) = mapper.calls - assert seen_job_id == job_id - assert seen_status is RecognitionStatus.done # mapper видит уже обновлённое задание (может его уточнить) - assert message.job_id == job_id - assert message.result_s3 is not None - assert message.result_s3.key == _result_key(file_id, job_id) + file = await _file_row(session_factory, file_id) + assert file["status"] is FileStatus.PROCESSING + assert file["updated_at"] == moment + assert file["current_recognition_job_id"] == job_id + assert (await _row(session_factory, job_id))["status"] is RecognitionStatus.processing @pytest.mark.integration -async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs: CommittedJobs, logs: list[Any]): - """Исключение mapper'а — откат всего: задание не изменено, s3_object не создан (дойдёт до timeout, D-6).""" - job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) - file_id = await _file_id(jobs, job_id) - before = await _row(session_factory, job_id) - total_before = await _s3_total(session_factory) +@pytest.mark.parametrize( + "file_status", [FileStatus.ERROR, FileStatus.PROCESSED, FileStatus.SUPERSEDED, FileStatus.PROCESSING] +) +async def test_ack_moves_file_only_from_uploaded(session_factory, jobs: CommittedJobs, file_status: FileStatus): + """R-5: ack ставит `PROCESSING` только из `UPLOADED` — запоздавший ack не воскрешает `ERROR` и не трогает + `PROCESSED`/`SUPERSEDED`; задание при этом подтверждается как обычно.""" + file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.published) + before = await _file_row(session_factory, file_id) - msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) - await _handle(session_factory, msg, mapper=RecordingMapper(fail=True)) + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1))) - assert await _row(session_factory, job_id) == before - assert await _s3_total(session_factory) == total_before - (record,) = _logged(logs, "Ошибка маппинга", "ERROR") + assert await _file_row(session_factory, file_id) == before + assert (await _row(session_factory, job_id))["status"] is RecognitionStatus.processing + + +@pytest.mark.integration +@pytest.mark.parametrize("file_status", [FileStatus.PROCESSING, FileStatus.UPLOADED]) +async def test_error_result_moves_file_to_error(session_factory, jobs: CommittedJobs, file_status: FileStatus): + """Сценарий «Парсер вернул ошибку»: файл — `ERROR`, причина — в задании (`error_code`).""" + file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.processing) + moment = datetime(2026, 10, 5, 9, 30, tzinfo=UTC) + + await _handle( + session_factory, + fake_msg(envelope(job_id, file_id, "error", error=_ERROR), result_msg_id(job_id, 1)), + now=lambda: moment, + ) + + file = await _file_row(session_factory, file_id) + assert (file["status"], file["updated_at"]) == (FileStatus.ERROR, moment) + assert (await _row(session_factory, job_id))["error_code"] == "UNSUPPORTED_FORMAT" + + +@pytest.mark.integration +@pytest.mark.parametrize("status", ["done", "needs_check"]) +async def test_success_result_leaves_processing_file_for_mapping(session_factory, jobs: CommittedJobs, status: str): + """Сценарий «Успешный результат»: файл остаётся `PROCESSING` до маппинга; строка файла не меняется вовсе.""" + file_id, job_id = await jobs.make_file_job( + file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing + ) + before = await _file_row(session_factory, file_id) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, status), result_msg_id(job_id, 1))) + + assert await _file_row(session_factory, file_id) == before + assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status) + + +@pytest.mark.integration +@pytest.mark.parametrize("status", ["done", "needs_check"]) +@pytest.mark.parametrize("job_status", [RecognitionStatus.published, RecognitionStatus.queued]) +async def test_success_result_without_ack_moves_uploaded_file_to_processing( + session_factory, jobs: CommittedJobs, status: str, job_status: RecognitionStatus +): + """R-4: ack потерялся (core NATS без JetStream) — результат `done`/`needs_check` переводит файл `UPLOADED → + PROCESSING`, иначе файл с готовым результатом не попал бы в цикл маппинга.""" + file_id, job_id = await jobs.make_file_job(job_status=job_status) + moment = datetime(2026, 10, 5, 10, 0, tzinfo=UTC) + + await _handle( + session_factory, fake_msg(envelope(job_id, file_id, status), result_msg_id(job_id, 1)), now=lambda: moment + ) + + file = await _file_row(session_factory, file_id) + assert (file["status"], file["updated_at"]) == (FileStatus.PROCESSING, moment) + assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status) + + +@pytest.mark.integration +@pytest.mark.parametrize("file_status", [FileStatus.PROCESSED, FileStatus.SUPERSEDED]) +@pytest.mark.parametrize("status", ["done", "error"]) +async def test_result_does_not_touch_processed_or_superseded_file( + session_factory, jobs: CommittedJobs, file_status: FileStatus, status: str +): + """`PROCESSED` и `SUPERSEDED` — итог маппинга / автозамены: результат обмена их не перезаписывает.""" + file_id, job_id = await jobs.make_file_job(file_status=file_status, job_status=RecognitionStatus.processing) + before = await _file_row(session_factory, file_id) + extra = {"error": _ERROR} if status == "error" else {} + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, status, **extra), result_msg_id(job_id, 1))) + + assert await _file_row(session_factory, file_id) == before + assert (await _row(session_factory, job_id))["status"] is RecognitionStatus(status) + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("status", "msg_id_fn", "expected_job"), + [ + ("processing", ack_msg_id, RecognitionStatus.processing), + ("done", result_msg_id, RecognitionStatus.done), + ("needs_check", result_msg_id, RecognitionStatus.needs_check), + ("error", result_msg_id, RecognitionStatus.error), + ], +) +async def test_message_of_non_current_job_does_not_touch_file( # noqa: PLR0913 - фикстуры и параметры + session_factory, jobs: CommittedJobs, status: str, msg_id_fn: Any, expected_job: RecognitionStatus +): + """Review Focus 1: у файла два задания, текущее — новое; ack/результат старого задания применяется к старому + заданию, но файл (статус, `updated_at`, `current_recognition_job_id`) не трогает.""" + file_id, old_job = await jobs.make_file_job(job_status=RecognitionStatus.published) + new_job = await jobs.add_job(file_id, status=RecognitionStatus.queued, current=True) + before = await _file_row(session_factory, file_id) + assert before["current_recognition_job_id"] == new_job + extra = {"error": _ERROR} if status == "error" else {} + + await _handle(session_factory, fake_msg(envelope(old_job, file_id, status, **extra), msg_id_fn(old_job, 1))) + + assert (await _row(session_factory, old_job))["status"] is expected_job + assert await _file_row(session_factory, file_id) == before + assert (await _row(session_factory, new_job))["status"] is RecognitionStatus.queued + + +@pytest.mark.integration +async def test_file_status_rolls_back_with_message_transaction( + session_factory, jobs: CommittedJobs, logs: list[Any], monkeypatch: pytest.MonkeyPatch +): + """Статус файла меняется в транзакции сообщения: ошибка после обновления задания откатывает и задание, и файл.""" + file_id, job_id = await jobs.make_file_job( + file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing + ) + job_before = await _row(session_factory, job_id) + file_before = await _file_row(session_factory, file_id) + original = rx.set_file_status_for_job + + async def failing(session: AsyncSession, job: RecognitionJob, status: FileStatus, **kwargs: Any) -> bool: + await original(session, job, status, **kwargs) + raise RuntimeError("подставная ошибка после обновления файла") + + monkeypatch.setattr(rx, "set_file_status_for_job", failing) + + await _handle(session_factory, fake_msg(envelope(job_id, file_id, "error", error=_ERROR), result_msg_id(job_id, 1))) + + assert await _row(session_factory, job_id) == job_before + assert await _file_row(session_factory, file_id) == file_before + (record,) = _logged(logs, "транзакция откачена", "ERROR") assert record["exception"] is not None @@ -627,17 +762,19 @@ async def test_mapper_error_rolls_back_whole_transaction(session_factory, jobs: @pytest.mark.integration -async def test_concurrent_same_message_applied_once(session_factory, jobs: CommittedJobs, logs: list[Any]): - job_id = await jobs.make_queued_job(status=RecognitionStatus.processing) - file_id = await _file_id(jobs, job_id) +async def test_concurrent_same_message_applied_once( + session_factory, jobs: CommittedJobs, logs: list[Any], monkeypatch: pytest.MonkeyPatch +): + file_id, job_id = await jobs.make_file_job(job_status=RecognitionStatus.processing) msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) - mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit + slow = SlowFileStatus(monkeypatch, delay=LOCK_HOLD_SECONDS) # первый держит строку, второй ждёт его commit - await asyncio.gather(*[_handle(session_factory, msg, mapper=mapper) for _ in range(2)]) + await asyncio.gather(*[_handle(session_factory, msg) for _ in range(2)]) assert len(await _s3_rows(session_factory, _result_key(file_id, job_id))) == 1 assert (await _row(session_factory, job_id))["result_msg_id"] == f"result:{job_id}:1" - assert len(mapper.calls) == 1 + assert slow.calls == [(job_id, FileStatus.PROCESSING)] # применён один раз + assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING assert len(_logged(logs, "дубликат", "DEBUG")) == 1 assert not _logged(logs, "неизвестного задания") @@ -684,26 +821,32 @@ async def test_ack_waits_for_publisher_lock(session_factory, jobs: CommittedJobs @pytest.mark.integration async def test_result_vs_watchdog_is_consistent(session_factory, jobs: CommittedJobs): - """Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object), либо - timeout (без результата), но не смесь. Порядок решает блокировка строки: кто первый, тот и финальный.""" + """Результат и вахтёр на заданиях с прошедшим сроком одновременно: каждое — либо done (с s3_object, файл + `PROCESSING`), либо timeout (без результата, файл `ERROR`), но не смесь. Порядок решает блокировка строки задания: + кто первый, тот и финальный (и задание, и файл меняются в порядке «задание → файл», без взаимоблокировок).""" past = datetime.now(UTC) - timedelta(minutes=1) - job_ids = [ - await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) for _ in range(RACE_JOBS) - ] - messages = [ - fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) for job_id in job_ids + pairs = [ + await jobs.make_file_job( + file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing, reply_deadline=past + ) + for _ in range(RACE_JOBS) ] + job_ids = [job_id for _, job_id in pairs] + messages = [fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) for file_id, job_id in pairs] await asyncio.gather(expire_deadlines(session_factory), *[_handle(session_factory, m) for m in messages]) for job_id in job_ids: job = await _row(session_factory, job_id) s3 = await _s3_rows(session_factory, _result_key(job["file_id"], job_id)) + file = await _file_row(session_factory, job["file_id"]) if job["status"] is RecognitionStatus.done: assert job["error_code"] is None assert job["result_msg_id"] == f"result:{job_id}:1" assert [r["id"] for r in s3] == [job["result_s3_object_id"]] + assert file["status"] is FileStatus.PROCESSING else: + assert file["status"] is FileStatus.ERROR assert job["status"] is RecognitionStatus.timeout assert job["error_code"] == "TIMEOUT" assert job["result_msg_id"] is None @@ -712,17 +855,21 @@ async def test_result_vs_watchdog_is_consistent(session_factory, jobs: Committed @pytest.mark.integration -async def test_watchdog_waits_for_result_transaction(session_factory, jobs: CommittedJobs): +async def test_watchdog_waits_for_result_transaction( + session_factory, jobs: CommittedJobs, monkeypatch: pytest.MonkeyPatch +): """Вахтёр запущен, пока обработчик результата держит строку: его UPDATE ждёт commit, перепроверяет статус на новой - версии строки (`done`) и задание не трогает.""" + версии строки (`done`) и задание не трогает; файл остаётся `PROCESSING` (не `ERROR`).""" past = datetime.now(UTC) - timedelta(minutes=1) - job_id = await jobs.make_queued_job(status=RecognitionStatus.processing, reply_deadline=past) - mapper = RecordingMapper(delay=LOCK_HOLD_SECONDS) - msg = fake_msg(envelope(job_id, await _file_id(jobs, job_id), "done"), result_msg_id(job_id, 1)) - handler = asyncio.create_task(_handle(session_factory, msg, mapper=mapper)) + file_id, job_id = await jobs.make_file_job( + file_status=FileStatus.PROCESSING, job_status=RecognitionStatus.processing, reply_deadline=past + ) + slow = SlowFileStatus(monkeypatch, delay=LOCK_HOLD_SECONDS) + msg = fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)) + handler = asyncio.create_task(_handle(session_factory, msg)) async def locked() -> None: # обработчик внутри транзакции, строка заблокирована - while not mapper.calls: + while not slow.calls: await asyncio.sleep(0.01) await asyncio.wait_for(locked(), 5) @@ -734,6 +881,7 @@ async def test_watchdog_waits_for_result_transaction(session_factory, jobs: Comm job = await _row(session_factory, job_id) assert job["status"] is RecognitionStatus.done assert job["error_code"] is None + assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING @pytest.mark.integration @@ -805,17 +953,17 @@ class _FakeService: @pytest.mark.integration async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, logs: list[Any]): - """queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений.""" + """queued → published (outbox) → processing (ack) → done + s3_object (результат) → повтор без изменений; + файл: UPLOADED → PROCESSING (ack) → PROCESSING (результат, ждёт цикл маппинга).""" settings = get_settings().OPENBAO.NATS result_subject = f"test.result.{uuid.uuid4().hex}" - handler = partial(handle_message, session_factory=session_factory, mapper=NoopMapper()) + handler = partial(handle_message, session_factory=session_factory) async def subscribe(conn: NatsConnection) -> None: await conn.subscribe_results(handler) core = NatsConnection(settings, on_connected=subscribe, result_subject=result_subject) - job_id = await jobs.make_queued_job() - file_id = await _file_id(jobs, job_id) + file_id, job_id = await jobs.make_file_job() service = _FakeService(job_id, result_subject) await core.start() try: @@ -834,6 +982,7 @@ async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, l await service.send(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)) job = await _wait_status(session_factory, job_id, RecognitionStatus.processing) assert job["started_at"] is not None + assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING assert (job["service_name"], job["service_version"]) == ("tabular-parser", "0.1.0") result = envelope(job_id, file_id, "done", metrics={"duration_ms": 10}) @@ -843,6 +992,7 @@ async def test_full_cycle_over_real_nats(session_factory, jobs: CommittedJobs, l assert (job["result_s3_object_id"], job["result_msg_id"]) == (s3["id"], f"result:{job_id}:1") assert job["finished_at"] is not None assert s3["bucket_role"] is S3BucketRole.results + assert (await _file_row(session_factory, file_id))["status"] is FileStatus.PROCESSING await service.send(result, result_msg_id(job_id, 1)) diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index bb082c8..25d3639 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -137,9 +137,9 @@ async def set_file_status_for_job(session, job: RecognitionJob, status: FileStat ``` Ошибка задания (`error`/`timeout`/`dlq`) ставит файлу `ERROR`; ack — `PROCESSING` только из `UPLOADED`; `done`/`needs_check` файл не трогают. -- [ ] **Step 1: Тесты** (integration, `CommittedJobs` + `make_stored_file`): ack → `PROCESSING`; `error` → `ERROR`; `done` → файл `PROCESSING`; `timeout` вахтёром → `ERROR`; `dlq` публикатором → `ERROR`; результат задания, которое уже не `current_recognition_job_id` файла → файл не меняется (Review Focus 1); существующие тесты inbox без `mapper` зелёные. -- [ ] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_recognition_inbox.py tests/test_recognition_exchange.py -q`, `make check`. -- [ ] **Step 5: Commit** `feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)`. +- [x] **Step 1: Тесты** (integration, `CommittedJobs` + `make_stored_file`): ack → `PROCESSING`; `error` → `ERROR`; `done` → файл `PROCESSING`; `timeout` вахтёром → `ERROR`; `dlq` публикатором → `ERROR`; результат задания, которое уже не `current_recognition_job_id` файла → файл не меняется (Review Focus 1); существующие тесты inbox без `mapper` зелёные. +- [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_recognition_inbox.py tests/test_recognition_exchange.py -q`, `make check`. +- [x] **Step 5: Commit** `feat(exchange): статусы stored_file по ack/ошибке/таймауту/dlq; inbox без ResultMapper (OQ-67)`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index a153537..0b96d8c 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -23,7 +23,7 @@ `schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт `POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль); `fixtures`-тест: реальный xls принят и `publish_pending` на `FakeNats` публикует запрос с ключом `uploads/{file_id}/{job_id}/…`. -- [ ] 2.2 **Статусы файла в обмене.** `recognition_exchange.py`: ack → `PROCESSING`; `error` → `ERROR`; вахтёр `timeout` → `ERROR`; +- [x] 2.2 **Статусы файла в обмене.** `recognition_exchange.py`: ack → `PROCESSING`; `error` → `ERROR`; вахтёр `timeout` → `ERROR`; публикатор `dlq` → `ERROR` (все — только при `current_recognition_job_id = job`); `ResultMapper`/`NoopMapper` удалены, `_map_result` убран, тесты inbox обновлены (`RecordingMapper` → проверка статуса файла). Тесты: сценарии спеки `recognition-job-exchange` (ack → PROCESSING, error → ERROR, timeout → ERROR, done → файл остаётся PROCESSING). -- 2.47.2 From 3b4d3a45fe39b4991717ea62e1f91efd35b9c957 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:13:58 +0400 Subject: [PATCH 09/35] =?UTF-8?q?fix(exchange):=20INVALID=5FREQUEST=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=B2=20ERROR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T4.1: публикатор, переводя задание в финальный error/INVALID_REQUEST, теперь в той же транзакции (под тем же FOR UPDATE) ставит файлу текущего задания ERROR (spec primary-files «Жизненный цикл статуса файла»); файл чужого задания не меняется. Тест test_invalid_request_marks_job_error_and_pass_continues[current|non_current]; CommittedJobs.make_file_job принимает original_name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/recognition_exchange.py | 2 ++ api/tests/factories.py | 15 ++++++++++++--- api/tests/test_recognition_exchange.py | 17 ++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 19934ce..78e18a5 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -251,6 +251,8 @@ async def _process_one( # noqa: PLR0913 - один шаг прохода: вс finished_at=now, ) ) + # финальная ошибка задания — файл текущего задания ERROR (spec primary-files «Жизненный цикл статуса файла») + await set_file_status_for_job(session, row, FileStatus.ERROR, now=now) report.invalid += 1 logger.error( "Запрос распознавания не собран по контракту, задание переведено в error. " diff --git a/api/tests/factories.py b/api/tests/factories.py index 551dcc2..5fffbcf 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -406,16 +406,25 @@ class CommittedJobs: *, file_status: FileStatus = FileStatus.UPLOADED, job_status: RecognitionStatus = RecognitionStatus.queued, + original_name: str | None = "osv.xls", **job_fields: Any, ) -> tuple[uuid.UUID, uuid.UUID]: """Файл с текущим заданием (`make_stored_file`: `current_recognition_job_id` = задание), зафиксированный в БД. - `job_fields` — поля `recognition_job` поверх фабрики (`reply_deadline`, `published_at`, `metrics`, …). - Возвращает `(stored_file.id, recognition_job.id)`. + `job_fields` — поля `recognition_job` поверх фабрики (`reply_deadline`, `published_at`, `metrics`, …); + `original_name=None` — файл без имени (запрос не собирается по контракту). Возвращает + `(stored_file.id, recognition_job.id)`. """ async with self._session_factory() as session, session.begin(): entity = await make_legal_entity(session, name="ООО Тест статусов файла") - file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=job_status) + file, job = await make_stored_file( + session, + entity=entity, + status=file_status, + job_status=job_status, + original_name=original_name or "file", + ) + file.original_name = original_name for name, value in job_fields.items(): setattr(job, name, value) await session.flush() diff --git a/api/tests/test_recognition_exchange.py b/api/tests/test_recognition_exchange.py index 8af29a9..b61be01 100644 --- a/api/tests/test_recognition_exchange.py +++ b/api/tests/test_recognition_exchange.py @@ -297,17 +297,23 @@ async def test_first_publication_msg_id_follows_attempt( @pytest.mark.integration +@pytest.mark.parametrize("current", [True, False], ids=["current", "non_current"]) async def test_invalid_request_marks_job_error_and_pass_continues( session_factory: async_sessionmaker[AsyncSession], jobs: CommittedJobs, nats: NatsConnection, subscriber: RequestSubscriber, + current: bool, ): - """Запрос не собирается по схеме (нет `original_name`) — задание `error/INVALID_REQUEST`, проход идёт дальше.""" - broken = await jobs.make_queued_job(original_name=None) + """Запрос не собирается по схеме (нет `original_name`) — задание `error/INVALID_REQUEST`, проход идёт дальше; файл + текущего задания — `ERROR` (финальная ошибка задания, spec primary-files), файл чужого задания не меняется.""" + clock = Clock() + file_id, broken = await jobs.make_file_job(original_name=None) + # новое задание — `processing`, чтобы публикатор его не трогал (в проходе только старое задание файла) + new_job = None if current else await jobs.add_job(file_id, status=RecognitionStatus.processing, current=True) good = await jobs.make_queued_job() - report = await _publish(session_factory, nats) + report = await _publish(session_factory, nats, clock) assert (report.invalid, report.published) == (1, 1) await subscriber.wait_for(good, 1) @@ -319,6 +325,11 @@ async def test_invalid_request_marks_job_error_and_pass_continues( assert job.error_message and "original_name" in job.error_message assert job.finished_at is not None assert job.request_msg_id is None + file = await jobs.file(file_id) + if current: + assert (file.status, file.updated_at) == (FileStatus.ERROR, clock()) + else: + assert (file.status, file.current_recognition_job_id, file.updated_at) == (FileStatus.UPLOADED, new_job, None) # --- повторы без ack и dlq --------------------------------------------------------------------------------------- -- 2.47.2 From f9306b8270af2ce0e4b855856d1cc24790711aa5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:20:22 +0400 Subject: [PATCH 10/35] =?UTF-8?q?feat(domain):=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20osvDocument=20=D0=B2=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA,=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=D0=B0,=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20(=C2=A71.3,=20=C2=A79.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domain/osv_mapping.py — чистые функции без БД и S3 (design D-6…D-9): normalize_account_code / account_root (ASSUMPTION(OQ-103)), build_entries (без TOTAL, дерево по parent_row_number, сироты, account_id по плану счетов, EntryDraft.counterparty_id = None по R-3), document_fields (DocumentDraft), counterparty_check, document_status (OQ-05), opening_matches (±0.01, OQ-02). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/osv_mapping.py | 228 +++++++++++++ api/tests/test_domain_osv_mapping.py | 305 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- 3 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 api/src/domain/osv_mapping.py create mode 100644 api/tests/test_domain_osv_mapping.py diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py new file mode 100644 index 0000000..743b0b1 --- /dev/null +++ b/api/src/domain/osv_mapping.py @@ -0,0 +1,228 @@ +"""Маппинг `osvDocument` результата распознавания в черновики `document` и `osv_entry` (§1.3, §1.5, §9.3). + +Чистые функции без БД, S3 и сессии (spec osv-result-mapping; design D-6…D-9): нормализация кода счёта, строки ОСВ +с деревом по `parent_row_number`, поля документа из шапки, проверка контрагента шапки, статус документа и сверка +сальдо на начало с Q1 для признака нарастающей. Резолв контрагента строки и запись в БД — в цикле маппинга +(`services/result_mapping.py`), он проставляет `EntryDraft.counterparty_id` через `dataclasses.replace`. +Суммы — `Decimal` из контракта без изменений, `float` не используется. +""" + +import re +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal + +from src.contract.result import OsvChecks, OsvDocument, OsvHeader, OsvRow +from src.domain.aliases import normalize_alias +from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, OsvGroupType, PeriodQuarter + +__all__ = [ + "ACCOUNT_SEGMENT_DIGITS", + "OPENING_TOLERANCE", + "DocumentDraft", + "EntryDraft", + "MappedSheet", + "account_root", + "build_entries", + "counterparty_check", + "document_fields", + "document_status", + "normalize_account_code", + "opening_matches", +] + +ACCOUNT_SEGMENT_DIGITS = 2 # субсчёт — две цифры: «60.1» → «60.01» +SUBACCOUNT_SEGMENT_INDEX = 1 # дополняется нулём только второй сегмент; первый и третий — как есть (§1.3) +ROOT_LEVEL = 0 +TOTAL_GROUP_TYPE = "TOTAL" # «Итого» не хранится как osv_entry (ASSUMPTION(OQ-01)) +OPENING_TOLERANCE = Decimal("0.01") # D-9: сальдо на начало совпадает с Q1 с точностью ±0.01 + +_NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}") +_ROOT_SEGMENT_RE = re.compile(r"[0-9]+") +_NON_DIGITS_RE = re.compile(r"[^0-9]") +_SEGMENT_SEPARATOR = "." + + +@dataclass(frozen=True) +class EntryDraft: + """Черновик `osv_entry`: id выдаётся заранее (`uuid4`), чтобы связать дерево до вставки.""" + + id: uuid.UUID + row_number: int + level: int + parent_id: uuid.UUID | None + row_value: str + group_type: OsvGroupType + group_name: str | None + data_type: OsvDataType | None + account_code: str | None + account_root: str | None + account_id: uuid.UUID | None + counterparty_name: str | None + counterparty_inn: str | None + # opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit + sums: tuple[Decimal | None, ...] + counterparty_id: uuid.UUID | None = None # проставляет цикл маппинга (резолв §3 п. 4), не build_entries + + +@dataclass(frozen=True) +class MappedSheet: + """Строки одного листа ОСВ: без `TOTAL`; `root` — корневая строка (level 0 без родителя) для сверки D-9.""" + + entries: list[EntryDraft] + root: EntryDraft | None + total_present: bool + orphans: tuple[int, ...] # row_number строк, чей parent_row_number не найден: вызывающий пишет предупреждение + + +@dataclass(frozen=True) +class DocumentDraft: + """Поля `document` (DBML v5.15), которые задаёт шапка листа ОСВ; остальное (ЮЛ, файл, статус) — вызывающий.""" + + osv_account_code: str + period_year: int | None + period_quarter: PeriodQuarter | None + is_cumulative: bool | None # по тексту периода; ядро может переопределить по данным (D-9, ASSUMPTION(OQ-02)) + locator: str + + +def normalize_account_code(raw: str) -> str: + """§1.3: «60.1» → «60.01», «76.7.1» → «76.07.1», «60.01.1», «76.АВ», «МЦ.04», «001» — без изменений. + + Сегменты по «.»; числовой (1–3 цифры) второй сегмент дополняется нулём слева до двух цифр, первый и третий + сегменты и буквенные сегменты остаются как в файле. + """ + segments = raw.strip().split(_SEGMENT_SEPARATOR) + if len(segments) > SUBACCOUNT_SEGMENT_INDEX: + sub = segments[SUBACCOUNT_SEGMENT_INDEX] + if _NUMERIC_SEGMENT_RE.fullmatch(sub): + segments[SUBACCOUNT_SEGMENT_INDEX] = sub.zfill(ACCOUNT_SEGMENT_DIGITS) + return _SEGMENT_SEPARATOR.join(segments) + + +def account_root(code: str) -> str | None: + """Первый сегмент кода («66», «001» — забалансовый), если он из цифр; иначе `None`.""" + # ASSUMPTION(OQ-103): счета с буквой в первом сегменте («МЦ.04», «ГТД») — без account_root + first = code.strip().split(_SEGMENT_SEPARATOR, 1)[0] + return first if _ROOT_SEGMENT_RE.fullmatch(first) else None + + +def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: + return ( + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + ) + + +def build_entries(doc: OsvDocument, chart: Mapping[str, uuid.UUID]) -> MappedSheet: + """D-6: все строки листа, кроме `TOTAL`, в черновики `osv_entry`. + + `parent_id` — по `parent_row_number` через карту `row_number → id`; родителя нет в карте → `parent_id = None` и + `row_number` в `orphans`. `account_code` — `account_code_raw` строки как пришёл (у не-счётов парсер его наследует + от ветки); `account_id` — точное совпадение нормализованного кода с `chart` (`chart_of_account.code → id`). + """ + rows = [row for row in doc.rows if row.group_type != TOTAL_GROUP_TYPE] + ids = {row.row_number: uuid.uuid4() for row in rows} + entries: list[EntryDraft] = [] + orphans: list[int] = [] + root: EntryDraft | None = None + for row in rows: + parent_id = ids.get(row.parent_row_number) if row.parent_row_number is not None else None + if row.parent_row_number is not None and parent_id is None: + orphans.append(row.row_number) + code = row.account_code_raw + entry = EntryDraft( + id=ids[row.row_number], + row_number=row.row_number, + level=row.level, + parent_id=parent_id, + row_value=row.row_value, + group_type=OsvGroupType(row.group_type), + group_name=row.group_name, + data_type=OsvDataType(row.data_type) if row.data_type else None, + account_code=code, + account_root=account_root(code) if code else None, + account_id=chart.get(normalize_account_code(code)) if code else None, + counterparty_name=row.counterparty_name, + counterparty_inn=row.counterparty_inn, + sums=_sums(row), + ) + if root is None and row.parent_row_number is None and row.level == ROOT_LEVEL: + root = entry + entries.append(entry) + return MappedSheet( + entries=entries, + root=root, + total_present=len(rows) != len(doc.rows), + orphans=tuple(orphans), + ) + + +def document_fields(doc: OsvDocument) -> DocumentDraft: + """D-7, §1.5: счёт (нормализованный), период и признак нарастающей — по шапке листа, `locator` — как пришёл.""" + header = doc.header + return DocumentDraft( + osv_account_code=normalize_account_code(header.account_code_raw), + period_year=header.period_year, + period_quarter=PeriodQuarter(header.period_quarter) if header.period_quarter else None, + is_cumulative=header.is_cumulative, + locator=doc.locator, + ) + + +def counterparty_check( + header: OsvHeader, owner_inn: str, owner_names_normalized: frozenset[str] +) -> DocumentCheckResult: + """D-8: организация в шапке ОСВ — владелец файла? + + ИНН шапки есть → `MATCH`, если его цифры равны ИНН владельца, иначе `MISMATCH`. ИНН нет → + `normalize_alias(organization_name)` среди нормализованных имени и алиасов владельца → `MATCH`, иначе `MISMATCH`. + Нет ни ИНН, ни имени (в том числе имя только из ОПФ и пунктуации) → `UNKNOWN`. + """ + header_inn = _NON_DIGITS_RE.sub("", header.organization_inn or "") + if header_inn: + return ( + DocumentCheckResult.MATCH + if header_inn == _NON_DIGITS_RE.sub("", owner_inn) + else DocumentCheckResult.MISMATCH + ) + name = normalize_alias(header.organization_name) + if not name: + return DocumentCheckResult.UNKNOWN + return DocumentCheckResult.MATCH if name in owner_names_normalized else DocumentCheckResult.MISMATCH + + +def _checks_clean(checks: OsvChecks | None) -> bool: + if checks is None: + return True + return ( + checks.total_row_matches_root is not False + and not checks.balance_identity_violations + and not checks.parent_sum_violations + ) + + +def document_status( + *, + job_done: bool, + checks: OsvChecks | None, + counterparty: DocumentCheckResult, + warnings: Sequence[str], +) -> DocumentStatus: + """§9.3, D-7 (OQ-05): `RECOGNIZED` только при задании `done`, `checks` без нарушений, контрагенте шапки не + `MISMATCH` и без предупреждений маппинга; иначе `NEEDS_CHECK`.""" + recognized = job_done and _checks_clean(checks) and counterparty != DocumentCheckResult.MISMATCH and not warnings + return DocumentStatus.RECOGNIZED if recognized else DocumentStatus.NEEDS_CHECK + + +def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool: + """D-9: сальдо на начало (дебет, кредит) строки `a` равно `b_opening` с точностью ±0.01; `None` = 0.""" + # ASSUMPTION(OQ-02): нарастающая определяется совпадением сальдо на начало корня с документом Q1 + zero = Decimal(0) + pairs = zip(a.sums[:2], b_opening, strict=True) + return all(abs((left or zero) - (right or zero)) <= OPENING_TOLERANCE for left, right in pairs) diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py new file mode 100644 index 0000000..48d79c4 --- /dev/null +++ b/api/tests/test_domain_osv_mapping.py @@ -0,0 +1,305 @@ +"""Чистый домен маппинга `osvDocument` (spec osv-result-mapping; design D-6…D-9; §1.3, §1.5, §9.3). + +Без БД и S3: строки → черновики `osv_entry`, шапка → черновик `document`, проверка контрагента шапки, статус документа, +сверка сальдо на начало для нарастающей (D-9). +""" + +import json +import uuid +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest + +from scripts.seed_lib.loaders import load_chart_of_accounts +from src.contract.result import OsvChecks, OsvDocument, OsvHeader +from src.domain.osv_mapping import ( + DocumentDraft, + EntryDraft, + account_root, + build_entries, + counterparty_check, + document_fields, + document_status, + normalize_account_code, + opening_matches, +) +from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, OsvGroupType, PeriodQuarter + +API_DIR = Path(__file__).resolve().parent.parent +SEED_DIR = API_DIR / "seed" +MINIMAL_EXAMPLE = API_DIR / "contracts" / "examples" / "result-osv-minimal.json" +GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" + +OWNER_INN = "3528052718" +OWNER_NAMES = frozenset({"русский бисквит", "рб"}) +SUM_COUNT = 6 + + +def _load_doc(path: Path) -> OsvDocument: + data = json.loads(path.read_text(encoding="utf-8")) + return OsvDocument.model_validate(data["documents"][0]) + + +def _seed_chart() -> dict[str, uuid.UUID]: + """`code → id` из seed-плана счетов (как `chart_of_account` после `make seed`).""" + codes = load_chart_of_accounts(SEED_DIR / "core", SEED_DIR) + return {code: uuid.uuid5(uuid.NAMESPACE_URL, f"chart:{code}") for code in codes} + + +def _header(**fields: Any) -> OsvHeader: + base: dict[str, Any] = {"organization_name": "", "account_code_raw": "60", "period_text": "2024 г."} + return OsvHeader.model_validate(base | fields) + + +def _entry(opening_debit: Decimal | None, opening_credit: Decimal | None) -> EntryDraft: + return EntryDraft( + id=uuid.uuid4(), + row_number=1, + level=0, + parent_id=None, + row_value="60", + group_type=OsvGroupType.ACCOUNT, + group_name=None, + data_type=OsvDataType.BU, + account_code="60", + account_root="60", + account_id=None, + counterparty_name=None, + counterparty_inn=None, + sums=(opening_debit, opening_credit, None, None, None, None), + ) + + +# --- нормализация кода счёта (§1.3) --------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("60.1", "60.01"), + ("76.7.1", "76.07.1"), + ("60.01.1", "60.01.1"), + ("58.03", "58.03"), + ("76.АВ", "76.АВ"), + ("МЦ.04", "МЦ.04"), + ("001", "001"), + ("60", "60"), + ], +) +def test_normalize_account_code(raw: str, expected: str) -> None: + assert normalize_account_code(raw) == expected + + +@pytest.mark.parametrize(("code", "expected"), [("60", "60"), ("60.01", "60"), ("001", "001"), ("МЦ.04", None)]) +def test_account_root_is_numeric_first_segment(code: str, expected: str | None) -> None: + """ASSUMPTION(OQ-103): нечисловой первый сегмент (забалансовые «МЦ.04») → `None`.""" + assert account_root(code) == expected + + +# --- строки ОСВ (D-6) ----------------------------------------------------------------------------------------------- + + +def test_build_entries_on_minimal_example() -> None: + doc = _load_doc(MINIMAL_EXAMPLE) + chart = {"60": uuid.uuid4()} + + sheet = build_entries(doc, chart) + + assert [e.row_number for e in sheet.entries] == [1, 2] + root, child = sheet.entries + assert sheet.root == root + assert sheet.total_present is False + assert sheet.orphans == () + assert root.parent_id is None + assert child.parent_id == root.id + assert root.id != child.id + assert root.group_type is OsvGroupType.ACCOUNT + assert child.group_type is OsvGroupType.COUNTERPARTY + assert child.group_name == "Контрагенты" + assert child.data_type is OsvDataType.BU + assert child.account_code == "60" + assert child.account_root == "60" + assert child.account_id == chart["60"] + assert child.counterparty_name == "ООО Лютик" + assert child.counterparty_inn == "7711111111" + assert child.counterparty_id is None # R-3: резолв — в цикле маппинга, не здесь + assert child.sums == ( + Decimal("0.00"), + Decimal("1000.00"), + Decimal("500.00"), + Decimal("200.50"), + Decimal("0.00"), + Decimal("700.50"), + ) + assert all(isinstance(value, Decimal) for value in child.sums) + + +def test_build_entries_skips_total_and_reports_orphans() -> None: + data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0] + rows = data["rows"] + total = rows[0] | {"row_number": 3, "row_value": "Итого", "group_type": "TOTAL", "account_code_raw": None} + orphan = rows[1] | {"row_number": 4, "parent_row_number": 99, "data_type": "NU", "account_code_raw": "МЦ.04"} + doc = OsvDocument.model_validate(data | {"rows": [*rows, total, orphan]}) + + sheet = build_entries(doc, {}) + + assert [e.row_number for e in sheet.entries] == [1, 2, 4] + assert sheet.total_present is True + assert sheet.orphans == (4,) + last = sheet.entries[-1] + assert last.parent_id is None + assert last.data_type is OsvDataType.NU + assert last.account_code == "МЦ.04" + assert last.account_root is None + assert last.account_id is None + + +def test_build_entries_keeps_absent_account_and_data_type() -> None: + data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0] + row = data["rows"][1] | {"account_code_raw": None, "data_type": None, "opening_debit": None} + doc = OsvDocument.model_validate(data | {"rows": [data["rows"][0], row]}) + + entry = build_entries(doc, {"60": uuid.uuid4()}).entries[1] + + assert entry.account_code is None + assert entry.account_root is None + assert entry.account_id is None + assert entry.data_type is None + assert entry.sums[0] is None + + +@pytest.mark.fixtures +def test_build_entries_on_golden_osv_66(fixtures_dir: Path) -> None: + doc = _load_doc(fixtures_dir / GOLDEN_OSV_66) + chart = _seed_chart() + + sheet = build_entries(doc, chart) + + assert len(sheet.entries) == len(doc.rows) - 1 # всё, кроме TOTAL (OQ-01) + assert sheet.total_present is True + assert sheet.orphans == () + assert sheet.root is not None + assert sheet.root.row_value == "66" + by_number = {e.row_number: e for e in sheet.entries} + by_id = {e.id: e for e in sheet.entries} + bakery = [e for e in sheet.entries if e.counterparty_name == "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ"] + assert [e.account_code for e in bakery] == ["66.03", "66.04"] + first = bakery[0] + assert first.parent_id is not None + assert by_id[first.parent_id].row_value == "66.03" + assert first.account_root == "66" + assert first.account_id == chart["66.03"] + assert by_number[13].account_id is None # 66.01 нет в seed-плане счетов + for entry in sheet.entries: + assert (entry.level == 0) == (entry.parent_id is None) + assert len(entry.sums) == SUM_COUNT + assert all(value is None or isinstance(value, Decimal) for value in entry.sums) + + +# --- документ (D-7) ------------------------------------------------------------------------------------------------- + + +def test_document_fields_on_minimal_example() -> None: + draft = document_fields(_load_doc(MINIMAL_EXAMPLE)) + + assert draft == DocumentDraft( + osv_account_code="60", + period_year=2025, + period_quarter=PeriodQuarter.Q1, + is_cumulative=False, + locator="sheet:Лист_1", + ) + + +def test_document_fields_normalize_account_and_keep_absent_period() -> None: + data = json.loads(MINIMAL_EXAMPLE.read_text(encoding="utf-8"))["documents"][0] + header = data["header"] | {"account_code_raw": "60.1", "period_year": None, "period_quarter": None} + header.pop("is_cumulative") + draft = document_fields(OsvDocument.model_validate(data | {"header": header})) + + assert draft.osv_account_code == "60.01" + assert draft.period_year is None + assert draft.period_quarter is None + assert draft.is_cumulative is None + + +@pytest.mark.fixtures +def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None: + draft = document_fields(_load_doc(fixtures_dir / GOLDEN_OSV_66)) + + assert draft == DocumentDraft( + osv_account_code="66", + period_year=2024, + period_quarter=PeriodQuarter.Q4, + is_cumulative=True, + locator="sheet:Лист_1", + ) + + +# --- проверка контрагента шапки (D-8) ------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("header", "expected"), + [ + ({"organization_name": "АО «Русский бисквит»", "organization_inn": OWNER_INN}, DocumentCheckResult.MATCH), + ({"organization_name": "", "organization_inn": " 3528 052718 "}, DocumentCheckResult.MATCH), + ({"organization_name": "АО «Русский бисквит»", "organization_inn": "3528000000"}, DocumentCheckResult.MISMATCH), + ({"organization_name": 'АО "Русский бисквит"'}, DocumentCheckResult.MATCH), # по нормализованному имени + ({"organization_name": "ООО РБ"}, DocumentCheckResult.MATCH), # по алиасу + ({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH), + ({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка + ({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN), + ], +) +def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None: + assert counterparty_check(_header(**header), OWNER_INN, OWNER_NAMES) is expected + + +# --- статус документа (§9.3, OQ-05) --------------------------------------------------------------------------------- + +CLEAN = OsvChecks(total_row_matches_root=True, balance_identity_violations=[], parent_sum_violations=[]) + + +@pytest.mark.parametrize( + ("job_done", "checks", "counterparty", "warnings", "expected"), + [ + (True, CLEAN, DocumentCheckResult.MATCH, [], DocumentStatus.RECOGNIZED), + (True, None, DocumentCheckResult.UNKNOWN, [], DocumentStatus.RECOGNIZED), + (False, CLEAN, DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK), + (True, OsvChecks(total_row_matches_root=False), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK), + (True, OsvChecks(balance_identity_violations=[5]), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK), + (True, OsvChecks(parent_sum_violations=[7]), DocumentCheckResult.MATCH, [], DocumentStatus.NEEDS_CHECK), + (True, CLEAN, DocumentCheckResult.MISMATCH, [], DocumentStatus.NEEDS_CHECK), + (True, CLEAN, DocumentCheckResult.MATCH, ["sheet=Лист_2: счёт 60 уже есть"], DocumentStatus.NEEDS_CHECK), + ], +) +def test_document_status( + job_done: bool, + checks: OsvChecks | None, + counterparty: DocumentCheckResult, + warnings: list[str], + expected: DocumentStatus, +) -> None: + status = document_status(job_done=job_done, checks=checks, counterparty=counterparty, warnings=warnings) + assert status is expected + + +# --- сверка сальдо на начало с Q1 (D-9) ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("entry", "q1_opening", "expected"), + [ + (_entry(Decimal("100.00"), None), (Decimal("100.00"), None), True), + (_entry(Decimal("100.00"), None), (Decimal("100.01"), None), True), # граница ±0.01 включительно + (_entry(Decimal("100.00"), None), (Decimal("99.99"), Decimal("0.00")), True), # None = 0 + (_entry(Decimal("100.00"), None), (Decimal("100.02"), None), False), + (_entry(None, Decimal("5.00")), (None, Decimal("4.98")), False), + (_entry(None, None), (Decimal("0.01"), Decimal("-0.01")), True), + ], +) +def test_opening_matches(entry: EntryDraft, q1_opening: tuple[Decimal | None, Decimal | None], expected: bool) -> None: + assert opening_matches(entry, q1_opening) is expected diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 25d3639..632cb99 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -168,9 +168,9 @@ def document_status(*, job_done: bool, checks: OsvChecks | None, counterparty: D def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool # ±0.01, None = 0 — для D-9 ``` -- [ ] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01. -- [ ] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`. -- [ ] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`. +- [x] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01. +- [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`. +- [x] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`. --- -- 2.47.2 From 764cd980c7591bf06cdcf67c3c13555f307d6025 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:25:16 +0400 Subject: [PATCH 11/35] =?UTF-8?q?OpenSpec/=D0=BF=D0=BB=D0=B0=D0=BD:=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20Task=205=20(design?= =?UTF-8?q?=20=E2=84=9625=E2=80=9326),=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=20golden=20=D0=9E=D0=A1=D0=92=5F?= =?UTF-8?q?66=20=E2=80=94=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 32ce749..5e38e48 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -182,3 +182,5 @@ rows без TOTAL, у каждого не-корня есть родитель, | 22 | 2026-10-05 | R-9 (Task 3): сбой S3 на одном файле → этот файл в `rejected` с `STORAGE_UNAVAILABLE`, откатывается его SAVEPOINT, принятые остаются; если отклонены все — 422 | модель частичного успеха D-4; клиент видит причину по файлу | вернуть 503 при отказе всех | | 23 | 2026-10-05 | R-10 (Task 3): загрузка файла — SAVEPOINT внутри транзакции запроса (не отдельная транзакция на файл, как в тексте спеки); недоменная ошибка БД валит весь запрос | указано в брифе плана; атомарность ответа | отдельные сессии на файл | | 24 | 2026-10-05 | R-11 (Task 3): `pg_advisory_xact_lock(ЮЛ, sha256)` против двойного клика — дубль не проскакивает; `requested_at = now` у задания; `size_bytes` у файла; `.jpeg` → JPG; `operation_id` у маршрута (OpenAPI) | гонка дедупа подтверждена тестом; FIFO outbox идёт по `requested_at` | убрать блокировку | +| 25 | 2026-10-05 | R-12 (Task 5): корень листа — первая строка `level == 0` без родителя (в голден-снимках ровно один; в двух пустых листах корня нет, `root = None`); имя шапки, пустое после `normalize_alias` (только ОПФ/пунктуация), без ИНН → `counterparty_check = UNKNOWN`; ИНН шапки сравнивается по цифрам; допуск `opening_matches` ±0.01 включительно по дебету и кредиту | шапка без идентифицирующего имени ничего не доказывает и не должна давать ложный MISMATCH | вернуть MISMATCH для пустой нормализованной шапки | +| 26 | 2026-10-05 | R-13: «20 строк» в плане (golden `ОСВ_66 за 2024`) исправлено на 9 (10 строк снимка минус TOTAL); критерий — «строк результата − 1» | в снимке 10 строк | нет | -- 2.47.2 From 4e9253204ca9e63167453f1c9dbdd0fd8fd53fa2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:25:25 +0400 Subject: [PATCH 12/35] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D0=B8=20tasks?= =?UTF-8?q?.md:=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=20golden=20=D0=9E=D0=A1=D0=92=5F66=20=E2=80=94=209?= =?UTF-8?q?=20(R-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md | 6 +++--- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 632cb99..d4616c3 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -168,7 +168,7 @@ def document_status(*, job_done: bool, checks: OsvChecks | None, counterparty: D def opening_matches(a: EntryDraft, b_opening: tuple[Decimal | None, Decimal | None]) -> bool # ±0.01, None = 0 — для D-9 ``` -- [x] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 20 строк, у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01. +- [x] **Step 1: Тесты** (unit): нормализация (таблица из 8 случаев), `account_root` (`60`, `001`, `МЦ.04` → None); `build_entries` на `api/contracts/examples/result-osv-minimal.json` (TOTAL отсутствует, parent выставлен, суммы `Decimal`) и на golden `../fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json` (маркер `fixtures`; `chart` — словарь из seed CSV): 9 строк (10 строк снимка минус TOTAL), у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code = "66.03"`, `account_id` задан; `counterparty_check` — ИНН совпал/не совпал, по имени, по алиасу, пустая шапка; `document_status` — 5 комбинаций; `opening_matches` на границе 0.01. - [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_osv_mapping.py -q`, `mypy src` strict для `src.domain.*`. - [x] **Step 5: Commit** `feat(domain): маппинг osvDocument в черновики документа и строк, нормализация кода счёта, проверки (§1.3, §9.3)`. @@ -228,7 +228,7 @@ async def run_mapping_loop(session_factory, storage, settings, *, sleep=asyncio. ``` `ExchangeTasks.start(session_factory, nats, settings, storage)` — третья задача `recognition-result-mapper` только при `storage is not None` (иначе warning «S3 не подключено: маппинг результатов не запущен»). -- [ ] **Step 1: Тесты** (integration, `committed_sessionmaker` + `FakeStorage`; результат — golden JSON `ОСВ_66 за 2024.json` с подставленными `job_id`/`file_id`): сценарии спеки `osv-result-mapping` «Результат смапплен» (документ 66/2024/Q4/cumulative, `locator`, 20 строк, `group_schema`), «Ключ вне папки» → `ERROR`, «Сбой с повтором» (monkeypatch `map_file` бросает 2 раза → attempts, 3-й → `ERROR`), «Идемпотентность» (вставить половину строк вручную → `process_once` → ровно один набор), «Два листа одного счёта» (JSON с двумя документами `60`) → один документ `NEEDS_CHECK` + warning, «Шапка другой организации» → `MISMATCH`/`NEEDS_CHECK`, «Нарастающая по данным» (сначала документ Q1 через фабрики с корнем, затем Q3 с теми же opening) → `is_cumulative = True`, «Чистый результат» → `RECOGNIZED`; результат без документов (`status: error`-подобный JSON с `documents: []` не валиден по контракту → fatal) → `ERROR` (Review Focus 2); результат для задания, которое уже не текущее → не трогает файл (Review Focus 1); `run_mapping_loop` с заглушкой sleep обрабатывает два кандидата подряд. +- [ ] **Step 1: Тесты** (integration, `committed_sessionmaker` + `FakeStorage`; результат — golden JSON `ОСВ_66 за 2024.json` с подставленными `job_id`/`file_id`): сценарии спеки `osv-result-mapping` «Результат смапплен» (документ 66/2024/Q4/cumulative, `locator`, 9 строк (10 строк снимка минус TOTAL), `group_schema`), «Ключ вне папки» → `ERROR`, «Сбой с повтором» (monkeypatch `map_file` бросает 2 раза → attempts, 3-й → `ERROR`), «Идемпотентность» (вставить половину строк вручную → `process_once` → ровно один набор), «Два листа одного счёта» (JSON с двумя документами `60`) → один документ `NEEDS_CHECK` + warning, «Шапка другой организации» → `MISMATCH`/`NEEDS_CHECK`, «Нарастающая по данным» (сначала документ Q1 через фабрики с корнем, затем Q3 с теми же opening) → `is_cumulative = True`, «Чистый результат» → `RECOGNIZED`; результат без документов (`status: error`-подобный JSON с `documents: []` не валиден по контракту → fatal) → `ERROR` (Review Focus 2); результат для задания, которое уже не текущее → не трогает файл (Review Focus 1); `run_mapping_loop` с заглушкой sleep обрабатывает два кандидата подряд. - [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_result_mapping.py -q`, `make check`. - [ ] **Step 5: Commit** `feat(mapping): фоновый цикл маппинга результата в document/osv_entry (OQ-69, OQ-70)`. @@ -303,7 +303,7 @@ async def delete(self, file_id) -> None # get_file(scope) → 404; есть do - Create: `api/tests/e2e/__init__.py`, `api/tests/e2e/test_osv_ingest_e2e.py`, `api/tests/e2e/conftest.py` - Modify: `Makefile` (`e2e: cd api && pytest -m e2e`, `check-full` включает `e2e`), `README.md` (как поднять профиль `services`) -**Interfaces:** тест (маркеры `integration`, `e2e`, `fixtures`): предусловия — `curl localhost:8001/healthcheck` tabular-parser из профиля `services` (`docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build tabular-parser`) и `NATS_ENABLED=true` у тестового приложения; иначе `pytest.skip("профиль services не поднят")`. Шаги: полное приложение `src.app.app` (lifespan с реальными NATS/MinIO из `.env.test`, `NATS_ENABLED=true` через monkeypatch settings) на отдельной БД (`committed_sessionmaker` + seed) → `POST /legal-entities/{id}/files` для ЮЛ, созданного фабрикой с ИНН `3528052718` (АО «Русский бисквит», владелец ОСВ 66) с `rb/2024-FY/ОСВ_66 за 2024.xls` → ждать до 60 с `GET /files/{id}` → `PROCESSED` → документ 66/2024/Q4, 20 строк, `counterparty_check = MATCH`, `status = RECOGNIZED`; очистка: удалить файл через API. +**Interfaces:** тест (маркеры `integration`, `e2e`, `fixtures`): предусловия — `curl localhost:8001/healthcheck` tabular-parser из профиля `services` (`docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build tabular-parser`) и `NATS_ENABLED=true` у тестового приложения; иначе `pytest.skip("профиль services не поднят")`. Шаги: полное приложение `src.app.app` (lifespan с реальными NATS/MinIO из `.env.test`, `NATS_ENABLED=true` через monkeypatch settings) на отдельной БД (`committed_sessionmaker` + seed) → `POST /legal-entities/{id}/files` для ЮЛ, созданного фабрикой с ИНН `3528052718` (АО «Русский бисквит», владелец ОСВ 66) с `rb/2024-FY/ОСВ_66 за 2024.xls` → ждать до 60 с `GET /files/{id}` → `PROCESSED` → документ 66/2024/Q4, 9 строк (10 строк снимка минус TOTAL), `counterparty_check = MATCH`, `status = RECOGNIZED`; очистка: удалить файл через API. - [ ] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. - [ ] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 0b96d8c..d74fd8e 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -60,7 +60,7 @@ - [ ] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле `services` (реальный `tabular-parser` против общего NATS/MinIO) — загрузка `rb/2024-FY/ОСВ_66 за 2024.xls` через API ядра, - ожидание `PROCESSED` (≤ 60 с), проверка документа и 20 строк; без профиля — skip с причиной. `make e2e` цель; `check-full` + ожидание `PROCESSED` (≤ 60 с), проверка документа и 9 строк (10 строк снимка минус TOTAL); без профиля — skip с причиной. `make e2e` цель; `check-full` его включает (skip допустим). - [ ] 5.2 **Документы.** README (загрузка: curl-пример, статусы файла и документа, цикл маппинга и настройки, API, удаление и замена, ограничения), CLAUDE.md (DBML v5.15, `domain/osv_mapping.py`, `services/primary_files.py`, `services/result_mapping.py`, -- 2.47.2 From 9658f809e759fb3ba82513f0877530835c0f0b50 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:27:42 +0400 Subject: [PATCH 13/35] =?UTF-8?q?feat(mapping):=20=D0=B8=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=20=D0=B8=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=98=D0=9D=D0=9D/=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8/=D0=B0=D0=BB=D0=B8=D0=B0=D1=81=D1=83=20(?= =?UTF-8?q?=C2=A73=20=D0=BF.=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CounterpartyIndex.load — два запроса по всему справочнику без policy (включая закрытые ЮЛ); resolve — ИНН, затем точное имя, затем normalize_alias по именам и алиасам, >1 кандидата → None; неизвестный ИНН не блокирует поиск по имени. owner_names — для counterparty_check. Отмечены шаги Task 6 и пункт 3.1 tasks.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/counterparties.py | 78 +++++++++++ api/tests/test_counterparty_resolve.py | 126 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 api/src/repositories/counterparties.py create mode 100644 api/tests/test_counterparty_resolve.py diff --git a/api/src/repositories/counterparties.py b/api/src/repositories/counterparties.py new file mode 100644 index 0000000..638d0f1 --- /dev/null +++ b/api/src/repositories/counterparties.py @@ -0,0 +1,78 @@ +"""Индекс контрагентов для резолва `osv_entry.counterparty_id` (§3 п. 4; spec osv-result-mapping «Резолв контрагента»; +design D-8). + +Индекс строится двумя запросами на файл (`legal_entity`, `legal_entity_alias`) по всему справочнику — без policy +видимости, включая закрытые ЮЛ (`close_date`): контрагент может быть организацией другого филиала. `resolve` — чистый +метод без обращений к БД. Функции не делают `commit`. +""" + +import uuid +from collections import defaultdict +from dataclasses import dataclass, field + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.aliases import normalize_alias +from src.domain.inn import normalize_inn +from src.models.directories import LegalEntity, LegalEntityAlias + + +@dataclass +class CounterpartyIndex: + """Словари поиска контрагента; значения-множества позволяют отличить «ровно один» от «неоднозначно».""" + + by_inn: dict[str, uuid.UUID] + by_exact_name: dict[str, set[uuid.UUID]] # ключ — `name.strip()` + by_normalized: dict[str, set[uuid.UUID]] # normalize_alias(name) ∪ normalized_alias алиасов; без пустого ключа + aliases_by_entity: dict[uuid.UUID, set[str]] = field(default_factory=dict) # для `owner_names` + + @classmethod + async def load(cls, session: AsyncSession) -> "CounterpartyIndex": + """Два запроса: `legal_entity(id, inn, name)` и `legal_entity_alias(legal_entity_id, normalized_alias)`.""" + by_inn: dict[str, uuid.UUID] = {} + by_exact_name: dict[str, set[uuid.UUID]] = defaultdict(set) + by_normalized: dict[str, set[uuid.UUID]] = defaultdict(set) + aliases_by_entity: dict[uuid.UUID, set[str]] = defaultdict(set) + + for entity_id, inn, name in await session.execute(select(LegalEntity.id, LegalEntity.inn, LegalEntity.name)): + by_inn[normalize_inn(inn)] = entity_id # `legal_entity.inn` уникален + by_exact_name[name.strip()].add(entity_id) + if normalized := normalize_alias(name): + by_normalized[normalized].add(entity_id) + + alias_rows = await session.execute(select(LegalEntityAlias.legal_entity_id, LegalEntityAlias.normalized_alias)) + for entity_id, normalized_alias in alias_rows: + if normalized_alias: + by_normalized[normalized_alias].add(entity_id) + aliases_by_entity[entity_id].add(normalized_alias) + + return cls( + by_inn=by_inn, + by_exact_name=dict(by_exact_name), + by_normalized=dict(by_normalized), + aliases_by_entity=dict(aliases_by_entity), + ) + + def resolve(self, inn: str | None, name: str | None) -> uuid.UUID | None: + """§3 п. 4: ИНН → точное имя → нормализованное имя/алиас; больше одного кандидата на шаге → `None`. + + ИНН, которого нет в справочнике, не блокирует поиск по имени (Review Focus 3). Ноль кандидатов на шаге — + переход к следующему; больше одного — ручное сопоставление, дальше не идём. + """ + if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None: + return found + if not name or not (stripped := name.strip()): + return None + if candidates := self.by_exact_name.get(stripped): + return next(iter(candidates)) if len(candidates) == 1 else None + if (normalized := normalize_alias(stripped)) and (candidates := self.by_normalized.get(normalized)): + return next(iter(candidates)) if len(candidates) == 1 else None + return None + + def owner_names(self, entity: LegalEntity) -> frozenset[str]: + """Нормализованные имя и алиасы ЮЛ — для `counterparty_check` (D-8); пустая нормализация отбрасывается.""" + names = set(self.aliases_by_entity.get(entity.id, ())) + if normalized := normalize_alias(entity.name): + names.add(normalized) + return frozenset(names) diff --git a/api/tests/test_counterparty_resolve.py b/api/tests/test_counterparty_resolve.py new file mode 100644 index 0000000..1153dd0 --- /dev/null +++ b/api/tests/test_counterparty_resolve.py @@ -0,0 +1,126 @@ +"""Индекс и резолв контрагентов (§3 п. 4; spec osv-result-mapping «Резолв контрагента»; design D-8; Review Focus 3). + +Интеграционные тесты на `db_session` с фабриками: загрузка индекса двумя запросами по всему справочнику (без policy), +ИНН → точное имя → нормализованное имя/алиас, неоднозначность → `None`, `owner_names` для `counterparty_check`. +""" + +import datetime + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.aliases import normalize_alias +from src.models.directories import LegalEntityAlias +from src.repositories.counterparties import CounterpartyIndex +from tests.factories import make_legal_entity + +pytestmark = pytest.mark.integration + + +async def _alias(session: AsyncSession, entity_id, alias: str) -> None: + session.add(LegalEntityAlias(legal_entity_id=entity_id, alias=alias, normalized_alias=normalize_alias(alias))) + await session.flush() + + +async def test_inn_alias_and_ambiguous(db_session: AsyncSession) -> None: + """Сценарий спеки: по ИНН, по алиасу, неоднозначно.""" + by_inn = await make_legal_entity(db_session, name="АО «Хлебозавод Ломоносов»", inn="3528052718") + by_alias = await make_legal_entity(db_session, name="ООО «РБ Холдинг»") + await _alias(db_session, by_alias.id, "Русский бисквит ООО") + await make_legal_entity(db_session, name="Альфаомега") + await make_legal_entity(db_session, name="АО «Альфаомега»") + + index = await CounterpartyIndex.load(db_session) + + assert index.resolve("3528052718", "что угодно") == by_inn.id + assert index.resolve(None, "Русский бисквит") == by_alias.id + # точного имени нет, два ЮЛ с нормализованным именем `альфаомега` + assert index.resolve(None, "ООО Альфаомега") is None + + +async def test_inn_is_compared_without_whitespace(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session, name="ООО Пробел", inn="7701234567") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve(" 7701 234 567 ", None) == entity.id + assert index.resolve("7701234568", None) is None + assert index.resolve("", "") is None + assert index.resolve(None, None) is None + assert index.resolve(None, " ") is None + + +async def test_unknown_inn_falls_through_to_alias_of_other_entity(db_session: AsyncSession) -> None: + """Review Focus 3: ИНН строки нет в справочнике — это не блокирует поиск по имени/алиасу другого ЮЛ.""" + owner = await make_legal_entity(db_session, name="ООО Совсем Другое", inn="1111111111") + await _alias(db_session, owner.id, "Пекарня Счастливочная") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve("9999999999", "Пекарня Счастливочная ООО") == owner.id + + +async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None: + unique = await make_legal_entity(db_session, name="ООО Точное Имя Один") + await make_legal_entity(db_session, name="ООО Дубль Имени", inn="2222222221") + await make_legal_entity(db_session, name="ООО Дубль Имени", inn="2222222222") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve(None, " ООО Точное Имя Один ") == unique.id + # >1 кандидата на шаге точного имени → None, без перехода дальше + assert index.resolve(None, "ООО Дубль Имени") is None + + +async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session, name="ООО «Нормализуемое Имя»") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve(None, "Нормализуемое имя, ООО") == entity.id + + +async def test_alias_ambiguity_between_name_and_alias_of_different_entities(db_session: AsyncSession) -> None: + first = await make_legal_entity(db_session, name="ООО Гамма Сервис") + second = await make_legal_entity(db_session, name="ООО Дельта") + await _alias(db_session, second.id, "Гамма сервис") + index = await CounterpartyIndex.load(db_session) + + assert first.id != second.id + assert index.resolve(None, "Гамма Сервис") is None + + +async def test_same_entity_by_name_and_alias_is_not_ambiguous(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session, name="ООО Эпсилон") + await _alias(db_session, entity.id, "Эпсилон") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve(None, "эпсилон ооо") == entity.id + + +async def test_closed_entity_is_resolved(db_session: AsyncSession) -> None: + closed = await make_legal_entity( + db_session, name="ООО Закрытое Зета", inn="3333333333", close_date=datetime.date(2023, 1, 1) + ) + index = await CounterpartyIndex.load(db_session) + + assert index.resolve("3333333333", None) == closed.id + assert index.resolve(None, "закрытое зета, ООО") == closed.id + + +async def test_owner_names(db_session: AsyncSession) -> None: + owner = await make_legal_entity(db_session, name="ООО «Пекарня Счастливочная»") + await _alias(db_session, owner.id, "Счастливочная пекарня") + other = await make_legal_entity(db_session, name="ООО Чужое") + await _alias(db_session, other.id, "Чужой алиас") + index = await CounterpartyIndex.load(db_session) + + assert index.owner_names(owner) == frozenset({"пекарня счастливочная", "счастливочная пекарня"}) + assert index.owner_names(other) == frozenset({"чужое", "чужой алиас"}) + + +async def test_owner_names_without_aliases_and_empty_normalization(db_session: AsyncSession) -> None: + only_form = await make_legal_entity(db_session, name="ООО") + plain = await make_legal_entity(db_session, name="ИП Иванов") + index = await CounterpartyIndex.load(db_session) + + assert index.owner_names(only_form) == frozenset() + assert index.owner_names(plain) == frozenset({"иванов"}) + # нормализация в пустую строку не даёт совпадений: пустой ключ в индексе не хранится + assert index.resolve(None, "АО") is None diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index d4616c3..c94a3f2 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -194,9 +194,9 @@ class CounterpartyIndex: def owner_names(self, entity: LegalEntity) -> frozenset[str] # normalize_alias(name) ∪ его алиасы — для counterparty_check ``` -- [ ] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`. -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`. +- [x] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`. +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index d74fd8e..fe6bd91 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -30,7 +30,7 @@ ## 3. Маппинг -- [ ] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc, +- [x] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc, chart, resolve)` (пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`; `repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None). Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json` -- 2.47.2 From 63343d3cba8880f3ff0bd49df736cfca85db260b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:29:52 +0400 Subject: [PATCH 14/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20Task=206=20(design=20=E2=84=9627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 5e38e48..f6b29bb 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -184,3 +184,4 @@ rows без TOTAL, у каждого не-корня есть родитель, | 24 | 2026-10-05 | R-11 (Task 3): `pg_advisory_xact_lock(ЮЛ, sha256)` против двойного клика — дубль не проскакивает; `requested_at = now` у задания; `size_bytes` у файла; `.jpeg` → JPG; `operation_id` у маршрута (OpenAPI) | гонка дедупа подтверждена тестом; FIFO outbox идёт по `requested_at` | убрать блокировку | | 25 | 2026-10-05 | R-12 (Task 5): корень листа — первая строка `level == 0` без родителя (в голден-снимках ровно один; в двух пустых листах корня нет, `root = None`); имя шапки, пустое после `normalize_alias` (только ОПФ/пунктуация), без ИНН → `counterparty_check = UNKNOWN`; ИНН шапки сравнивается по цифрам; допуск `opening_matches` ±0.01 включительно по дебету и кредиту | шапка без идентифицирующего имени ничего не доказывает и не должна давать ложный MISMATCH | вернуть MISMATCH для пустой нормализованной шапки | | 26 | 2026-10-05 | R-13: «20 строк» в плане (golden `ОСВ_66 за 2024`) исправлено на 9 (10 строк снимка минус TOTAL); критерий — «строк результата − 1» | в снимке 10 строк | нет | +| 27 | 2026-10-05 | R-14 (Task 6): ИНН ищется точно после `normalize_inn` (пробелы), мусорные значения проваливаются к имени; пустая нормализация (имя из одних ОПФ) не попадает в индекс; при единственном точном совпадении имени ЮЛ побеждает, даже если нормализованное имя неоднозначно (неоднозначность оценивается по шагам, D-8) | буквальное чтение §3 п. 4 «на любом шаге > 1 → NULL» | поменять приоритет шагов | -- 2.47.2 From e704b028ecfd59ee119dce33063d6ae37c073001 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:44:45 +0400 Subject: [PATCH 15/35] =?UTF-8?q?feat(mapping):=20=D1=84=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20=D0=B2=20document/osv=5F?= =?UTF-8?q?entry=20(OQ-69,=20OQ-70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/result_mapping.py: pick_candidate (FOR UPDATE OF job SKIP LOCKED, файл PROCESSING и задание текущее), проверка ключа results/{file_id}/{job_id}/ и бакета, чтение S3 вне транзакции, map_file одной транзакцией (очистка документов/строк файла, документ на лист ОСВ, повтор счёта — предупреждение и NEEDS_CHECK первого листа, строки пакетами по 1000, резолв контрагента COUNTERPARTY+BU/SUM (R-3), нарастающая по Q1 (D-9), group_schema, metrics.mapping_warnings, PROCESSED через set_file_status_for_job). Неповторяемые ошибки — сразу ERROR с metrics.mapping_error; прочие — откат и mapping_attempts, на MAPPING_MAX_ATTEMPTS — ERROR. run_mapping_loop — третья задача ExchangeTasks при подключённом S3 (иначе предупреждение). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/app.py | 14 +- api/src/services/recognition_exchange.py | 28 +- api/src/services/result_mapping.py | 573 +++++++++++++++ api/tests/test_app.py | 55 +- api/tests/test_exchange_lifespan_nats.py | 2 +- api/tests/test_result_mapping.py | 690 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 8 files changed, 1353 insertions(+), 17 deletions(-) create mode 100644 api/src/services/result_mapping.py create mode 100644 api/tests/test_result_mapping.py diff --git a/api/src/app.py b/api/src/app.py index 3d59e9f..8f72817 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -7,8 +7,9 @@ middleware (gzip, proxy headers, CORS по `cors_options`; при DEBUG=False п middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66). Приложение собирает `create_app(settings)`. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает -состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и -вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения +состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор, +вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) и, если S3 подключено, цикл маппинга результатов +(`src/services/result_mapping.py`, Срез 2c D-1) — и останавливает их до закрытия NATS. Сообщения `recognition.result` (ack и результаты сервисов) обрабатывает inbox `handle_message` того же модуля с фабрикой сессий приложения (inbox меняет задание и статус файла; маппинг результата в доменные таблицы — отдельный цикл, Срез 2c D-1). Остановка: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД; каждый шаг независим — ошибка одного @@ -120,9 +121,10 @@ def _create_nats() -> NatsConnection | None: return NatsConnection(settings.OPENBAO.NATS, on_connected=_subscribe_results) -def _start_exchange(nats: NatsConnection) -> ExchangeTasks: - """Фоновые задачи обмена: публикатор outbox и вахтёр сроков (только вместе с NATS, т.е. при NATS_ENABLED=true).""" - return ExchangeTasks.start(async_session_maker, nats, settings) +def _start_exchange(nats: NatsConnection, storage: S3Storage | None) -> ExchangeTasks: + """Фоновые задачи обмена: публикатор outbox, вахтёр сроков и цикл маппинга результатов (последний — только при + подключённом S3); все — только вместе с NATS, т.е. при NATS_ENABLED=true.""" + return ExchangeTasks.start(async_session_maker, nats, settings, storage) def _smb_configured() -> bool: @@ -182,7 +184,7 @@ async def lifespan(app: FastAPI): app.state.nats = _create_nats() if app.state.nats is not None: await app.state.nats.start() # подключение в фоне: старт приложения не ждёт NATS - app.state.exchange = _start_exchange(app.state.nats) # публикатор сам ждёт соединения + app.state.exchange = _start_exchange(app.state.nats, app.state.storage) # публикатор ждёт NATS сам logger.info("Приложение запущено") yield diff --git a/api/src/services/recognition_exchange.py b/api/src/services/recognition_exchange.py index 78e18a5..dec11f8 100644 --- a/api/src/services/recognition_exchange.py +++ b/api/src/services/recognition_exchange.py @@ -76,9 +76,11 @@ from src.contract import ( ) from src.core.config import Settings from src.core.nats import NatsPublishError +from src.core.storage import ObjectStorage from src.models.enums import FileStatus, RecognitionPipeline, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile from src.services.file_status import set_file_status_for_job, set_file_status_for_jobs +from src.services.result_mapping import run_mapping_loop WATCHDOG_PERIOD_SECONDS = 30 # период вахтёра сроков (spec «Таймаут ожидания результата») PUBLISH_BATCH_SIZE = 50 # заданий за один проход публикатора (design D-6, LIMIT 50) @@ -434,15 +436,20 @@ async def run_watchdog_loop( class ExchangeTasks: - """Фоновые задачи обмена в lifespan (design D-6): публикатор и вахтёр. Запускаются только при `NATS_ENABLED=true` - (вызывающий код создаёт их вместе с `NatsConnection`) и останавливаются до закрытия NATS.""" + """Фоновые задачи обмена в lifespan (design D-6; Срез 2c D-1): публикатор, вахтёр и — при подключённом S3 — цикл + маппинга результатов (`services/result_mapping.py`). Запускаются только при `NATS_ENABLED=true` (вызывающий код + создаёт их вместе с `NatsConnection`) и останавливаются до закрытия NATS.""" def __init__(self, tasks: Sequence[asyncio.Task[None]]) -> None: self.tasks = tuple(tasks) @classmethod def start( - cls, session_factory: async_sessionmaker[AsyncSession], nats: RequestPublisher, settings: Settings + cls, + session_factory: async_sessionmaker[AsyncSession], + nats: RequestPublisher, + settings: Settings, + storage: ObjectStorage | None = None, ) -> "ExchangeTasks": publisher = asyncio.create_task( run_publisher_loop( @@ -458,12 +465,23 @@ class ExchangeTasks: run_watchdog_loop(session_factory, period_seconds=WATCHDOG_PERIOD_SECONDS), name="recognition-deadline-watchdog", ) + tasks = [publisher, watchdog] + if storage is not None: + tasks.append( + asyncio.create_task( + run_mapping_loop(session_factory, storage, settings), name="recognition-result-mapper" + ) + ) + else: + logger.warning("S3 не подключено: маппинг результатов не запущен") logger.info( - "Фоновые задачи обмена запущены: публикатор (период {poll} с), вахтёр сроков (период {watchdog} с)", + "Фоновые задачи обмена запущены: публикатор (период {poll} с), вахтёр сроков (период {watchdog} с), " + "маппинг результатов: {mapping}", poll=settings.OUTBOX_POLL_SECONDS, watchdog=WATCHDOG_PERIOD_SECONDS, + mapping=f"период {settings.MAPPING_POLL_SECONDS} с" if storage is not None else "нет", ) - return cls([publisher, watchdog]) + return cls(tasks) async def stop(self) -> None: """Отменяет задачи и дожидается их завершения; исключения наружу не выходят.""" diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py new file mode 100644 index 0000000..63cb0a2 --- /dev/null +++ b/api/src/services/result_mapping.py @@ -0,0 +1,573 @@ +"""Цикл маппинга результата распознавания в `document` + `osv_entry` (spec osv-result-mapping; design D-1, D-2, D-3, +D-7, D-9; рулинги R-3, R-4, R-5; docs/context/05-domain-rules.md §1.3, §1.5, §3 п. 4, §9.2, §9.3). + +Inbox файл результата не читает (OQ-67, OQ-69): признак «результат ещё не смапплен» — `stored_file.status = +PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recognition_job_id`) уже `done`/`needs_check`. Один проход +`process_once` обрабатывает одного кандидата в три шага: + +1. короткая транзакция: `pick_candidate` (`FOR UPDATE OF recognition_job SKIP LOCKED`) и проверка ключа результата + (бакет хранилища, папка `results/{file_id}/{job_id}/`, OQ-70); +2. БЕЗ транзакции: чтение объекта из S3 и валидация `RecognitionResult` v1.x — блокировки не держатся на время сети; +3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, + вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат + старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, + документы и строки, `group_schema`, предупреждения, `PROCESSED`. + +Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) +— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции +`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`. Статус файла — только через +`services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг не аудируются +(ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — отдельно. +""" + +import asyncio +import dataclasses +import uuid +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from loguru import logger +from pydantic import ValidationError +from sqlalchemy import Integer, delete, func, insert, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.contract.result import OsvDocument, RecognitionResult +from src.core.config import Settings +from src.core.storage import ObjectNotFoundError, ObjectStorage +from src.domain.osv_mapping import ( + EntryDraft, + build_entries, + counterparty_check, + document_fields, + document_status, + opening_matches, +) +from src.models.accounting import ChartOfAccount +from src.models.directories import LegalEntity +from src.models.enums import ( + DocumentCheckResult, + DocumentType, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from src.repositories.counterparties import CounterpartyIndex +from src.services.file_status import set_file_status_for_job + +__all__ = [ + "ENTRY_BATCH_SIZE", + "MAPPING_ATTEMPTS_KEY", + "MAPPING_ERROR_KEY", + "MAPPING_WARNINGS_KEY", + "Candidate", + "MappingFatal", + "map_file", + "pick_candidate", + "process_once", + "run_mapping_loop", + "validate_result_key", +] + +MAPPING_ATTEMPTS_KEY = "mapping_attempts" # счётчик неудачных попыток в `recognition_job.metrics` (D-2) +MAPPING_ERROR_KEY = "mapping_error" # причина `ERROR` файла по маппингу — для карточки файла +MAPPING_WARNINGS_KEY = "mapping_warnings" # предупреждения маппинга файла, список строк (D-7, R-6) +ENTRY_BATCH_SIZE = 1000 # строк `osv_entry` на один INSERT: 23 колонки × 1000 < 32767 параметров asyncpg +MAPPING_ERROR_MAX_LENGTH = 2000 +RESULTS_PREFIX = "results" +MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check) +CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) +RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3 +ROOT_LEVEL = 0 +_FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."}) + +Clock = Callable[[], datetime] +SleepFn = Callable[[float], Awaitable[None]] + + +class MappingFatal(Exception): + """Ошибка без повтора (ключ, объект, схема, нет документов): файл сразу `ERROR` (D-2).""" + + +@dataclass(frozen=True) +class Candidate: + """Задание с результатом, ожидающим маппинга, и ключ файла результата.""" + + job_id: uuid.UUID + file_id: uuid.UUID + bucket: str + key: str + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +def _short(text: str) -> str: + return text if len(text) <= MAPPING_ERROR_MAX_LENGTH else text[: MAPPING_ERROR_MAX_LENGTH - 1] + "…" + + +def _metrics(job: RecognitionJob) -> dict[str, Any]: + """Копия `metrics` задания (не объект — `{}`): JSONB меняется только присваиванием нового dict.""" + return dict(job.metrics) if isinstance(job.metrics, dict) else {} + + +def _attempts(metrics: Mapping[str, Any]) -> int: + value = metrics.get(MAPPING_ATTEMPTS_KEY) + return value if isinstance(value, int) and not isinstance(value, bool) else 0 + + +# --- шаг 1: кандидат и ключ ----------------------------------------------------------------------------------------- + + +async def pick_candidate(session: AsyncSession, *, max_attempts: int) -> Candidate | None: + """Следующее задание для маппинга (D-1): `done`/`needs_check`, текущее для файла в `PROCESSING`, попыток меньше + `max_attempts`, со ссылкой на файл результата; старейшее по `finished_at`. Строка задания блокируется, занятые + другим проходом пропускаются (несколько инстансов ядра).""" + job = RecognitionJob + attempts = func.coalesce(job.metrics[MAPPING_ATTEMPTS_KEY].astext.cast(Integer), 0) + stmt = ( + select(job.id, job.file_id, S3Object.bucket, S3Object.key) + .join(StoredFile, (StoredFile.id == job.file_id) & (StoredFile.current_recognition_job_id == job.id)) + .join(S3Object, S3Object.id == job.result_s3_object_id) + .where( + job.status.in_(MAPPABLE_JOB_STATUSES), + StoredFile.status == FileStatus.PROCESSING, + attempts < max_attempts, + ) + .order_by(job.finished_at.asc().nulls_last(), job.id) + .limit(1) + .with_for_update(of=job, skip_locked=True) + ) + row = (await session.execute(stmt)).one_or_none() + if row is None: + return None + return Candidate(job_id=row.id, file_id=row.file_id, bucket=row.bucket, key=row.key) + + +def validate_result_key(candidate: Candidate, storage_bucket: str) -> None: + """OQ-70: результат читается только из бакета хранилища и только из папки своего задания + `results/{file_id}/{job_id}/<имя>`; сегменты `.`/`..`/пустые запрещены. Иначе — `MappingFatal`.""" + if candidate.bucket != storage_bucket: + raise MappingFatal( + f"файл результата в чужом бакете: {candidate.bucket}/{candidate.key} (ожидался бакет {storage_bucket})" + ) + prefix = f"{RESULTS_PREFIX}/{candidate.file_id}/{candidate.job_id}/" + rest = candidate.key[len(prefix) :] if candidate.key.startswith(prefix) else None + if rest is None or any(segment in _FORBIDDEN_KEY_SEGMENTS for segment in rest.split("/")): + raise MappingFatal(f"ключ файла результата вне папки задания: {candidate.key} (ожидалось {prefix}<имя>)") + + +# --- шаг 2: чтение результата --------------------------------------------------------------------------------------- + + +def _osv_documents(result: RecognitionResult) -> list[OsvDocument]: + return [document for document in result.documents if isinstance(document, OsvDocument)] + + +async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult: + """Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`.""" + try: + content = await storage.get_bytes(candidate.key) + except ObjectNotFoundError as exc: + raise MappingFatal(f"файл результата не найден в S3: {candidate.bucket}/{candidate.key}") from exc + try: + result = RecognitionResult.model_validate_json(content) + except ValidationError as exc: + raise MappingFatal(f"файл результата не соответствует схеме recognition-result: {exc}") from exc + if result.job_id != candidate.job_id or result.file_id != candidate.file_id: + raise MappingFatal( + f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" + ) + if not _osv_documents(result): + raise MappingFatal("в результате нет документов ОСВ") + return result + + +# --- шаг 3: маппинг файла ------------------------------------------------------------------------------------------- + + +def _with_counterparty(entry: EntryDraft, index: CounterpartyIndex) -> EntryDraft: + """R-3: `counterparty_id` — только строкам `COUNTERPARTY` с `data_type ∈ {BU, SUM}` (§3 п. 4, без policy).""" + if entry.group_type is not OsvGroupType.COUNTERPARTY or entry.data_type not in RESOLVED_DATA_TYPES: + return entry + return dataclasses.replace(entry, counterparty_id=index.resolve(entry.counterparty_inn, entry.counterparty_name)) + + +def _entry_values( + entry: EntryDraft, *, document_id: uuid.UUID, job_id: uuid.UUID, owner_id: uuid.UUID +) -> dict[str, Any]: + """Строка `osv_entry` (DBML v5.15) из черновика; суммы — `Decimal` из контракта без изменений.""" + opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit = entry.sums + return { + "id": entry.id, + "recognition_job_id": job_id, + "document_id": document_id, + "owner_legal_entity_id": owner_id, + "parent_entry_id": entry.parent_id, + "group_type": entry.group_type, + "group_name": entry.group_name, + "row_number": entry.row_number, + "level": entry.level, + "row_value": entry.row_value, + "data_type": entry.data_type, + "account_code": entry.account_code, + "account_id": entry.account_id, + "account_root": entry.account_root, + "counterparty_name": entry.counterparty_name, + "counterparty_inn": entry.counterparty_inn, + "counterparty_id": entry.counterparty_id, + "opening_debit": opening_debit, + "opening_credit": opening_credit, + "turnover_debit": turnover_debit, + "turnover_credit": turnover_credit, + "closing_debit": closing_debit, + "closing_credit": closing_credit, + "is_modified": False, + } + + +async def _clear_file(session: AsyncSession, file_id: uuid.UUID) -> None: + """D-3: строки и документы файла от прерванной попытки (файл в `PROCESSING` правок аналитика не содержит).""" + documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(documents))) + await session.execute(delete(Document).where(Document.stored_file_id == file_id)) + + +async def _q1_root_opening( + session: AsyncSession, *, legal_entity_id: uuid.UUID, account_code: str, year: int +) -> tuple[Decimal | None, Decimal | None] | None: + """D-9: сальдо на начало корня документа ОСВ Q1 того же ЮЛ, счёта и года из файла не `SUPERSEDED` (при нескольких — + последний созданный); корень — строка уровня 0 без родителя с минимальным `row_number`. Нет Q1/корня — `None`.""" + document_id = ( + await session.execute( + select(Document.id) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id == legal_entity_id, + Document.document_type == DocumentType.OSV, + Document.osv_account_code == account_code, + Document.period_year == year, + Document.period_quarter == PeriodQuarter.Q1, + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by(Document.created_at.desc().nulls_last(), Document.id) + .limit(1) + ) + ).scalar_one_or_none() + if document_id is None: + return None + root = ( + await session.execute( + select(OsvEntry.opening_debit, OsvEntry.opening_credit) + .where( + OsvEntry.document_id == document_id, + OsvEntry.level == ROOT_LEVEL, + OsvEntry.parent_entry_id.is_(None), + ) + .order_by(OsvEntry.row_number) + .limit(1) + ) + ).one_or_none() + return (root.opening_debit, root.opening_credit) if root is not None else None + + +def _group_schema(sheet: OsvDocument) -> dict[str, Any]: + return { + "locator": sheet.locator, + "groups": [{"type": group.type, "name": group.name, "has_inn": group.has_inn} for group in sheet.group_schema], + } + + +def _split_duplicates(sheets: Sequence[OsvDocument]) -> tuple[list[OsvDocument], dict[str, list[str]], list[str]]: + """D-7: первый лист каждого счёта — документ; повтор счёта в файле — только предупреждение, которое относится и к + первому листу (он получает `NEEDS_CHECK`). Возвращает листы-документы, предупреждения по `locator` первого листа и + все предупреждения о повторах.""" + first_locator: dict[str, str] = {} + kept: list[OsvDocument] = [] + by_locator: dict[str, list[str]] = {} + warnings: list[str] = [] + for sheet in sheets: + code = document_fields(sheet).osv_account_code + if code in first_locator: + warning = f"sheet={sheet.locator}: счёт {code} уже есть в листе {first_locator[code]}" + warnings.append(warning) + by_locator.setdefault(first_locator[code], []).append(warning) + continue + first_locator[code] = sheet.locator + kept.append(sheet) + return kept, by_locator, warnings + + +async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные + session: AsyncSession, + *, + job: RecognitionJob, + file: StoredFile, + result: RecognitionResult, + chart: Mapping[str, uuid.UUID], + index: CounterpartyIndex, + now: datetime, +) -> list[str]: + """Маппинг результата в транзакции вызывающего (D-3, D-6…D-9); возвращает предупреждения маппинга файла. + + Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки + пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из + `PROCESSING` и только для текущего задания). Без commit. + """ + sheets = _osv_documents(result) + if not sheets: + raise MappingFatal("в результате нет документов ОСВ") + owner = await session.get(LegalEntity, file.legal_entity_id) + if owner is None: + raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}") + owner_names = index.owner_names(owner) + job_done = job.status is RecognitionStatus.done + + await _clear_file(session, file.id) + kept, duplicate_warnings, warnings = _split_duplicates(sheets) + documents: list[Document] = [] + entries: list[dict[str, Any]] = [] + for sheet in kept: + fields = document_fields(sheet) + mapped = build_entries(sheet, chart) + sheet_warnings: list[str] = [] # предупреждения листа сверх повторов счёта (те уже в `warnings`) + if mapped.orphans: + sheet_warnings.append( + f"sheet={sheet.locator}: строки без родителя в результате: {', '.join(map(str, mapped.orphans))}" + ) + is_cumulative = fields.is_cumulative + if ( + fields.period_quarter in CUMULATIVE_CHECK_QUARTERS + and is_cumulative is False + and fields.period_year is not None + and mapped.root is not None + ): + q1_opening = await _q1_root_opening( + session, + legal_entity_id=file.legal_entity_id, + account_code=fields.osv_account_code, + year=fields.period_year, + ) + # ASSUMPTION(OQ-02): квартальная ОСВ с сальдо на начало, равным Q1, — нарастающая + if q1_opening is not None and opening_matches(mapped.root, q1_opening): + is_cumulative = True + sheet_warnings.append( + f"sheet={sheet.locator}: период {sheet.header.period_text}: нарастающая по данным Q1" + ) + check = counterparty_check(sheet.header, owner.inn, owner_names) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.OSV, + osv_account_code=fields.osv_account_code, + period_year=fields.period_year, + period_quarter=fields.period_quarter, + is_cumulative=is_cumulative, + locator=fields.locator, + status=document_status( + job_done=job_done, + checks=sheet.checks, + counterparty=check, + warnings=[*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings], + ), + period_check=DocumentCheckResult.UNKNOWN, # до версии консолидации (Срез 4) + counterparty_check=check, + created_at=now, + updated_at=now, + ) + documents.append(document) + warnings.extend(sheet_warnings) + entries.extend( + _entry_values( + _with_counterparty(entry, index), + document_id=document.id, + job_id=job.id, + owner_id=file.legal_entity_id, + ) + for entry in mapped.entries + ) + + session.add_all(documents) + await session.flush() + for start in range(0, len(entries), ENTRY_BATCH_SIZE): + await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) + + job.group_schema = [_group_schema(sheet) for sheet in sheets] + job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} + await session.flush() + if not await set_file_status_for_job( + session, job, FileStatus.PROCESSED, now=now, only_from={FileStatus.PROCESSING} + ): + raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее") + await session.refresh(file, ["status", "updated_at"]) + return warnings + + +# --- проход и цикл -------------------------------------------------------------------------------------------------- + + +async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob | None: + stmt = ( + select(RecognitionJob) + .where(RecognitionJob.id == job_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def _lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | None: + stmt = ( + select(StoredFile).where(StoredFile.id == file_id).with_for_update().execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def _fail(session: AsyncSession, candidate: Candidate, message: str, now: datetime) -> None: + """Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего).""" + job = await _lock_job(session, candidate.job_id) + if job is None: + return + job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)} + await session.flush() + changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) + logger.error( + "Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, " + "file_id={file_id}, file_changed={changed}, error={error}", + job_id=candidate.job_id, + file_id=candidate.file_id, + changed=changed, + error=message, + ) + + +async def _register_failure( + session_factory: async_sessionmaker[AsyncSession], + candidate: Candidate, + exc: Exception, + *, + max_attempts: int, + now: datetime, +) -> None: + """D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR`.""" + async with session_factory() as session, session.begin(): + job = await _lock_job(session, candidate.job_id) + if job is None: + return + metrics = _metrics(job) + attempts = _attempts(metrics) + 1 + metrics[MAPPING_ATTEMPTS_KEY] = attempts + exhausted = attempts >= max_attempts + if exhausted: + metrics[MAPPING_ERROR_KEY] = _short( + f"маппинг не удался после {attempts} попыток: {type(exc).__name__}: {exc}" + ) + job.metrics = metrics + await session.flush() + if exhausted: + await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) + logger.opt(exception=exc).error( + "Ошибка маппинга результата, транзакция откачена. error_category=mapping_error, job_id={job_id}, " + "file_id={file_id}, attempts={attempts}, file_error={exhausted}", + job_id=candidate.job_id, + file_id=candidate.file_id, + attempts=attempts, + exhausted=exhausted, + ) + + +async def _map_candidate( + session_factory: async_sessionmaker[AsyncSession], candidate: Candidate, result: RecognitionResult, clock: Clock +) -> None: + """Шаг 3: транзакция маппинга. Задание и файл перечитываются под блокировкой (порядок «задание → файл»); файл уже + не ждёт результата этого задания (его опередили, задание не текущее — Review Focus 1) — ничего не меняется.""" + async with session_factory() as session, session.begin(): + job = await _lock_job(session, candidate.job_id) + file = await _lock_file(session, candidate.file_id) + if ( + job is None + or file is None + or job.status not in MAPPABLE_JOB_STATUSES + or file.status is not FileStatus.PROCESSING + or file.current_recognition_job_id != job.id + ): + logger.info( + "Маппинг пропущен: файл уже не ждёт результата этого задания. job_id={job_id}, file_id={file_id}", + job_id=candidate.job_id, + file_id=candidate.file_id, + ) + return + rows = await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) + chart = {code: account_id for code, account_id in rows} + index = await CounterpartyIndex.load(session) + warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=clock()) + logger.info( + "Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}", + job_id=candidate.job_id, + file_id=candidate.file_id, + warnings=len(warnings), + ) + + +async def process_once( + session_factory: async_sessionmaker[AsyncSession], + storage: ObjectStorage, + settings: Settings, + *, + now: Clock | None = None, +) -> bool: + """Один проход цикла маппинга (модель шагов — в docstring модуля). `False` — кандидатов нет; `True` — кандидат + обработан (смапплен, отклонён, отложен до следующей попытки или пропущен, потому что его опередили).""" + clock = now or _utcnow + async with session_factory() as session, session.begin(): + candidate = await pick_candidate(session, max_attempts=settings.MAPPING_MAX_ATTEMPTS) + if candidate is None: + return False + try: + validate_result_key(candidate, storage.bucket) + except MappingFatal as exc: + await _fail(session, candidate, str(exc), clock()) + return True + + try: + result = await _load_result(storage, candidate) # шаг 2 — вне транзакции: блокировки не ждут S3 + await _map_candidate(session_factory, candidate, result, clock) + except MappingFatal as exc: + async with session_factory() as session, session.begin(): + await _fail(session, candidate, str(exc), clock()) + except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор в следующем проходе + await _register_failure( + session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock() + ) + return True + + +async def run_mapping_loop( + session_factory: async_sessionmaker[AsyncSession], + storage: ObjectStorage, + settings: Settings, + *, + sleep: SleepFn = asyncio.sleep, +) -> None: + """Бесконечный цикл маппинга: после обработанного кандидата — следующий сразу, без кандидатов — пауза + `MAPPING_POLL_SECONDS`; ошибка прохода логируется, цикл продолжается.""" + while True: + processed = False + try: + processed = await process_once(session_factory, storage, settings) + except Exception as exc: + logger.exception( + "Ошибка прохода цикла маппинга. error_category=mapping_loop_error, error_type={error_type}, " + "error={error}", + error_type=type(exc).__name__, + error=repr(exc), + ) + await sleep(0 if processed else settings.MAPPING_POLL_SECONDS) diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 63bb893..c739343 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -122,7 +122,7 @@ def fake_engine(monkeypatch: pytest.MonkeyPatch) -> _FakeEngine: @pytest.fixture(autouse=True) def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]: - """Циклы публикатора и вахтёра подменены ожиданием: быстрые тесты lifespan не ходят в БД.""" + """Циклы публикатора, вахтёра и маппинга подменены ожиданием: быстрые тесты lifespan не ходят в БД.""" started: list[str] = [] async def publisher(session_factory: Any, nats: Any, **kwargs: Any) -> None: @@ -133,8 +133,13 @@ def exchange_loops(monkeypatch: pytest.MonkeyPatch) -> list[str]: started.append("watchdog") await asyncio.Event().wait() + async def mapper(session_factory: Any, storage: Any, settings: Any, **kwargs: Any) -> None: + started.append("mapper") + await asyncio.Event().wait() + monkeypatch.setattr(recognition_exchange, "run_publisher_loop", publisher) monkeypatch.setattr(recognition_exchange, "run_watchdog_loop", watchdog) + monkeypatch.setattr(recognition_exchange, "run_mapping_loop", mapper) return started @@ -308,6 +313,54 @@ async def test_lifespan_starts_exchange_tasks_and_stops_them_before_nats( assert fake_nats.stopped +@pytest.mark.usefixtures("_no_smb") +async def test_lifespan_starts_mapping_loop_with_storage(monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str]): + """S3 подключено: третья фоновая задача — цикл маппинга результатов (Срез 2c, design D-1) с хранилищем + приложения; без S3 — только публикатор и вахтёр и предупреждение в лог.""" + storage = SimpleNamespace(client=_FakeS3Client(), bucket="arm-ka") + received: list[Any] = [] + + async def connect_s3() -> SimpleNamespace: + return storage + + async def mapper(session_factory: Any, mapper_storage: Any, settings: Any, **kwargs: Any) -> None: + received.append(mapper_storage) + exchange_loops.append("mapper") + await asyncio.Event().wait() + + monkeypatch.setattr(recognition_exchange, "run_mapping_loop", mapper) + monkeypatch.setattr(app_module, "_connect_s3", connect_s3) + monkeypatch.setattr(app_module, "_create_nats", _RecordingNats) + + async with app_module.lifespan(app_module.app): + exchange = app_module.app.state.exchange + assert exchange is not None + assert sorted(task.get_name() for task in exchange.tasks) == [ + "recognition-deadline-watchdog", + "recognition-outbox-publisher", + "recognition-result-mapper", + ] + await asyncio.sleep(0) + assert sorted(exchange_loops) == ["mapper", "publisher", "watchdog"] + assert received == [storage] + assert all(task.done() for task in exchange.tasks) + + async def unavailable() -> None: + return None + + exchange_loops.clear() + monkeypatch.setattr(app_module, "_connect_s3", unavailable) + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="WARNING") + try: + async with app_module.lifespan(app_module.app): + await asyncio.sleep(0) + assert sorted(exchange_loops) == ["publisher", "watchdog"] + finally: + logger.remove(handler_id) + assert any("маппинг результатов не запущен" in record["message"] for record in records) + + @pytest.mark.usefixtures("_no_smb") async def test_lifespan_without_nats_starts_no_exchange_tasks( monkeypatch: pytest.MonkeyPatch, exchange_loops: list[str] diff --git a/api/tests/test_exchange_lifespan_nats.py b/api/tests/test_exchange_lifespan_nats.py index af7e739..eea74f7 100644 --- a/api/tests/test_exchange_lifespan_nats.py +++ b/api/tests/test_exchange_lifespan_nats.py @@ -44,7 +44,7 @@ app_module = importlib.import_module("src.app") READY_TIMEOUT = 5.0 WAIT_TIMEOUT = 10.0 NATS_MONITOR_PORT = 8222 # мониторинг NATS из docs/dev/docker-compose.yml -EXCHANGE_TASKS = 2 # публикатор и вахтёр +EXCHANGE_TASKS = 2 # публикатор и вахтёр; S3 не подключено — цикла маппинга нет # --- инфраструктура --------------------------------------------------------------------------------------------- diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py new file mode 100644 index 0000000..14e1854 --- /dev/null +++ b/api/tests/test_result_mapping.py @@ -0,0 +1,690 @@ +"""Цикл маппинга результата распознавания в `document`/`osv_entry` (spec osv-result-mapping; design D-1, D-2, D-3, D-7, +D-9; рулинги R-3, R-4, R-5; Review Focus 1, 2). + +Интеграционные тесты — на отдельной временной БД (`committed_sessionmaker`: цикл работает в собственных транзакциях), +результат лежит в `FakeStorage` под `results/{file_id}/{job_id}/result.json`. Синтетические результаты строятся из +примера контракта `contracts/examples/result-osv-minimal.json`; сценарий «Результат смапплен» и замер скорости — на +golden-снимках парсера (маркер `fixtures`). +""" + +import asyncio +import copy +import json +import time +import uuid +from collections.abc import Callable +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from loguru import logger +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.config import Settings, get_settings +from src.models.accounting import ChartOfAccount +from src.models.directories import LegalEntity +from src.models.enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, RecognitionJob, StoredFile +from src.models.osv import OsvEntry +from src.services import result_mapping as rm +from src.services.result_mapping import ( + MAPPING_ATTEMPTS_KEY, + MAPPING_ERROR_KEY, + MAPPING_WARNINGS_KEY, + Candidate, + MappingFatal, + process_once, + run_mapping_loop, + validate_result_key, +) +from tests.conftest import FakeStorage +from tests.factories import make_document, make_legal_entity, make_result_object, make_stored_file + +API_DIR = Path(__file__).resolve().parents[1] +MINIMAL_RESULT = API_DIR / "contracts" / "examples" / "result-osv-minimal.json" +GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" +GOLDEN_OSV_60 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_60 за 2024.json" +BUCKET = "arm-ka" +OWNER_NAME = "АО «Русский бисквит»" +PERF_BUDGET_SECONDS = 2.0 # цель плана: маппинг ~1000 строк ОСВ_60 за 2024 быстрее 2 с + +pytestmark = pytest.mark.integration + + +# --- данные --------------------------------------------------------------------------------------------------------- + + +def _minimal() -> dict[str, Any]: + return json.loads(MINIMAL_RESULT.read_text("utf-8")) + + +def _golden(fixtures_dir: Path, path: Path) -> dict[str, Any]: + data = json.loads((fixtures_dir / path).read_text("utf-8")) + # у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна) + data["service"] = {"version": "golden", **data["service"]} + return data + + +def _osv(result: dict[str, Any]) -> dict[str, Any]: + return result["documents"][0] + + +def _root(rows: list[dict[str, Any]]) -> dict[str, Any]: + return next(row for row in rows if row["level"] == 0 and row["parent_row_number"] is None) + + +def _settings() -> Settings: + return get_settings() + + +def _now() -> datetime: + return datetime.now(UTC) + + +async def _prepare( # noqa: PLR0913 - каждое поле подготовки задаётся тестом явно + maker: async_sessionmaker[AsyncSession], + storage: FakeStorage, + result: dict[str, Any], + *, + entity: LegalEntity | None = None, + owner_name: str = OWNER_NAME, + owner_inn: str | None = None, + job_status: RecognitionStatus = RecognitionStatus.done, + file_status: FileStatus = FileStatus.PROCESSING, + key: str | None = None, +) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: + """ЮЛ-владелец, файл `PROCESSING` с текущим заданием `done`, результат в `storage`; `(file_id, job_id, entity_id)`. + + `job_id`/`file_id` результата подставляются; `key` — подменить ключ `s3_object` результата (OQ-70).""" + async with maker() as session, session.begin(): + owner = entity or await make_legal_entity(session, name=owner_name, inn=owner_inn) + file, job = await make_stored_file(session, entity=owner, status=file_status, job_status=job_status) + job.finished_at = _now() + job.metrics = copy.deepcopy(result.get("metrics")) # как inbox: метрики сервиса из конверта + payload = copy.deepcopy(result) | {"job_id": str(job.id), "file_id": str(file.id)} + s3 = await make_result_object(session, job=job, storage=storage, result=payload) + if key is not None: + storage.objects[key] = storage.objects[s3.key] + s3.key = key + return file.id, job.id, owner.id + + +async def _file(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> StoredFile: + async with maker() as session: + file = await session.get(StoredFile, file_id) + assert file is not None + return file + + +async def _job(maker: async_sessionmaker[AsyncSession], job_id: uuid.UUID) -> RecognitionJob: + async with maker() as session: + job = await session.get(RecognitionJob, job_id) + assert job is not None + return job + + +async def _documents(maker: async_sessionmaker[AsyncSession], file_id: uuid.UUID) -> list[Document]: + async with maker() as session: + rows = await session.execute(select(Document).where(Document.stored_file_id == file_id)) + return list(rows.scalars()) + + +async def _entries(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> list[OsvEntry]: + async with maker() as session: + rows = await session.execute( + select(OsvEntry).where(OsvEntry.document_id == document_id).order_by(OsvEntry.row_number) + ) + return list(rows.scalars()) + + +# --- проверка ключа результата (без БД) ---------------------------------------------------------------------------- + + +def _candidate(key: str, bucket: str = BUCKET) -> Candidate: + return Candidate(job_id=JOB_ID, file_id=FILE_ID, bucket=bucket, key=key) + + +JOB_ID = uuid.UUID("3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01") +FILE_ID = uuid.UUID("a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02") + + +@pytest.mark.parametrize( + "key", + [f"results/{FILE_ID}/{JOB_ID}/result.json", f"results/{FILE_ID}/{JOB_ID}/sub/result.json"], +) +def test_validate_result_key_accepts_job_folder(key: str) -> None: + validate_result_key(_candidate(key), BUCKET) + + +@pytest.mark.parametrize( + ("key", "bucket"), + [ + (f"results/{uuid.uuid4()}/{JOB_ID}/result.json", BUCKET), # чужой файл + (f"results/{FILE_ID}/{uuid.uuid4()}/result.json", BUCKET), # чужое задание + (f"uploads/{FILE_ID}/{JOB_ID}/result.json", BUCKET), # не папка результатов + (f"results/{FILE_ID}/{JOB_ID}/", BUCKET), # нет имени объекта + (f"results/{FILE_ID}/{JOB_ID}/../{uuid.uuid4()}/result.json", BUCKET), # выход из папки + (f"results/{FILE_ID}/{JOB_ID}/result.json", "other-bucket"), # чужой бакет + ], +) +def test_validate_result_key_rejects_outside_job_folder(key: str, bucket: str) -> None: + with pytest.raises(MappingFatal): + validate_result_key(_candidate(key, bucket), BUCKET) + + +# --- сценарии спеки ------------------------------------------------------------------------------------------------ + + +@pytest.mark.fixtures +async def test_golden_osv_66_mapped( # noqa: PLR0915 - один сценарий спеки целиком + committed_sessionmaker: async_sessionmaker[AsyncSession], fixtures_dir: Path +): + """«Результат смапплен», «Дерево и счета»: документ 66/2024/Q4/нарастающая, строки без TOTAL, `group_schema`.""" + maker, storage = committed_sessionmaker, FakeStorage() + golden = _golden(fixtures_dir, GOLDEN_OSV_66) + file_id, job_id, entity_id = await _prepare(maker, storage, golden) + + assert await process_once(maker, storage, _settings()) is True + + file = await _file(maker, file_id) + assert file.status is FileStatus.PROCESSED + assert file.updated_at is not None + [document] = await _documents(maker, file_id) + assert document.document_type is DocumentType.OSV + assert document.osv_account_code == "66" + assert document.period_year == 2024 # noqa: PLR2004 - год из шапки снимка + assert document.period_quarter is PeriodQuarter.Q4 + assert document.is_cumulative is True + assert document.locator == "sheet:Лист_1" + assert document.legal_entity_id == entity_id + assert document.status is DocumentStatus.RECOGNIZED # done, проверки чистые, шапка = владелец по имени + assert document.counterparty_check is DocumentCheckResult.MATCH + assert document.period_check is DocumentCheckResult.UNKNOWN + + rows = _osv(golden)["rows"] + entries = await _entries(maker, document.id) + assert len(entries) == len(rows) - 1 # всё, кроме TOTAL + total_rows = {row["row_number"] for row in rows if row["group_type"] == "TOTAL"} + assert total_rows and not total_rows & {entry.row_number for entry in entries} # в enum DBML TOTAL нет + by_row = {entry.row_number: entry for entry in entries} + for row in rows: + if row["group_type"] == "TOTAL": + continue + entry = by_row[row["row_number"]] + assert entry.recognition_job_id == job_id + assert entry.owner_legal_entity_id == entity_id + assert entry.is_modified is False + assert entry.level == row["level"] + assert entry.row_value == row["row_value"] + for name in ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit"): + expected = row.get(name) + assert getattr(entry, name) == (Decimal(expected) if expected is not None else None) + if row["parent_row_number"] is None: + assert entry.parent_entry_id is None + else: + assert entry.parent_entry_id == by_row[row["parent_row_number"]].id + + async with maker() as session: + chart_6603 = (await session.execute(select(ChartOfAccount.id).where(ChartOfAccount.code == "66.03"))).scalar() + [yug] = [e for e in entries if e.account_code == "66.03" and e.group_type is OsvGroupType.COUNTERPARTY] + assert yug.counterparty_name == "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" + parent = next(e for e in entries if e.id == yug.parent_entry_id) + assert parent.account_code == "66.03" + assert parent.group_type is OsvGroupType.ACCOUNT + assert yug.account_root == "66" + assert yug.account_id == chart_6603 is not None + + job = await _job(maker, job_id) + assert job.group_schema == [ + { + "locator": "sheet:Лист_1", + "groups": [ + {"type": "ACCOUNT", "name": "Счет", "has_inn": None}, + {"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": None}, + ], + } + ] + assert job.metrics[MAPPING_WARNINGS_KEY] == [] + assert job.metrics["rows"] == 9 # noqa: PLR2004 - метрики сервиса сохранены рядом с ключами маппинга + + +@pytest.mark.fixtures +async def test_mapping_performance_osv_60(committed_sessionmaker: async_sessionmaker[AsyncSession], fixtures_dir: Path): + """Цель плана: маппинг самой длинной ОСВ (`ОСВ_60 за 2024`, ~1000 строк) — быстрее 2 с на один проход цикла.""" + maker, storage = committed_sessionmaker, FakeStorage() + golden = _golden(fixtures_dir, GOLDEN_OSV_60) + file_id, _, _ = await _prepare(maker, storage, golden) + + started = time.perf_counter() + assert await process_once(maker, storage, _settings()) is True + elapsed = time.perf_counter() - started + + [document] = await _documents(maker, file_id) + rows = _osv(golden)["rows"] + entries = await _entries(maker, document.id) + total_rows = sum(1 for row in rows if row["group_type"] == "TOTAL") + assert len(entries) == len(rows) - total_rows + logger.info("Маппинг ОСВ_60 за 2024: {rows} строк за {elapsed:.3f} с", rows=len(entries), elapsed=elapsed) + print(f"\nPERF: ОСВ_60 за 2024 — {len(entries)} строк за {elapsed:.3f} с") # noqa: T201 - замер в отчёт + assert elapsed < PERF_BUDGET_SECONDS + + +async def test_result_key_outside_job_folder_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """«Ключ результата вне папки задания»: файл `ERROR` с ошибкой маппинга, документов нет, повтора нет.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare( + maker, storage, _minimal(), key=f"results/{uuid.uuid4()}/{uuid.uuid4()}/result.json" + ) + + assert await process_once(maker, storage, _settings()) is True + + file = await _file(maker, file_id) + assert file.status is FileStatus.ERROR + job = await _job(maker, job_id) + assert "results/" in job.metrics[MAPPING_ERROR_KEY] + assert MAPPING_ATTEMPTS_KEY not in job.metrics # неповторяемая ошибка — без счётчика попыток + assert await _documents(maker, file_id) == [] + assert await process_once(maker, storage, _settings()) is False # повтора нет + + +async def test_missing_result_object_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Объекта результата нет в S3 — неповторяемая ошибка (D-2): файл `ERROR`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + storage.objects.clear() + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + assert "не найден" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + + +@pytest.mark.parametrize( + ("documents", "status", "message"), + [ + ([], "needs_check", "схем"), # контракт требует documents ≥ 1 — результат не проходит схему + ( + [{"document_type": "BALANCE", "locator": "pages:1", "header": {}, "columns": [], "lines": []}], + "needs_check", + "нет документов ОСВ", + ), + ], +) +async def test_result_without_osv_documents_is_error( + committed_sessionmaker: async_sessionmaker[AsyncSession], documents: list[Any], status: str, message: str +): + """Review Focus 2: результат без документов ОСВ → файл `ERROR` «нет документов ОСВ», не исключение цикла.""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() | {"status": status, "documents": documents} + file_id, job_id, _ = await _prepare(maker, storage, result, job_status=RecognitionStatus.needs_check) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + assert message in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + assert await _documents(maker, file_id) == [] + + +async def test_mapping_failure_retries_then_error( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """«Сбой маппинга с повтором»: откат (документов нет), `mapping_attempts` 1, 2 — файл `PROCESSING`; третья + неудача — `ERROR` с `metrics.mapping_error`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + real_map_file = rm.map_file + calls: list[int] = [] + + async def failing_map_file(session: AsyncSession, **kwargs: Any) -> list[str]: + await real_map_file(session, **kwargs) # вставка выполнена — и откатывается вместе с транзакцией + calls.append(1) + raise RuntimeError("подставная ошибка БД при вставке строк") + + monkeypatch.setattr(rm, "map_file", failing_map_file) + + for attempt in (1, 2): + assert await process_once(maker, storage, _settings()) is True + job = await _job(maker, job_id) + assert job.metrics[MAPPING_ATTEMPTS_KEY] == attempt + assert MAPPING_ERROR_KEY not in job.metrics + assert (await _file(maker, file_id)).status is FileStatus.PROCESSING + assert await _documents(maker, file_id) == [] + + assert await process_once(maker, storage, _settings()) is True + job = await _job(maker, job_id) + assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию + assert "подставная ошибка БД" in job.metrics[MAPPING_ERROR_KEY] + assert (await _file(maker, file_id)).status is FileStatus.ERROR + assert len(calls) == 3 # noqa: PLR2004 - три попытки + assert await process_once(maker, storage, _settings()) is False + + +async def test_repeated_mapping_is_idempotent(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """«Повторный маппинг идемпотентен»: остатки прерванной вставки (документ и половина строк) удаляются, у файла + ровно один набор документов и строк.""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() + rows = _osv(result)["rows"] + rows.extend( + rows[-1] | {"row_number": number, "counterparty_name": f"ООО Лютик-{number}", "counterparty_inn": None} + for number in range(3, 7) + ) + file_id, job_id, entity_id = await _prepare(maker, storage, result) + async with maker() as session, session.begin(): + file = await session.get(StoredFile, file_id) + job = await session.get(RecognitionJob, job_id) + assert file is not None and job is not None + stale = await make_document(session, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK) + for row in rows[: len(rows) // 2]: + session.add( + OsvEntry( + recognition_job_id=job_id, + document_id=stale.id, + owner_legal_entity_id=entity_id, + row_number=row["row_number"], + level=row["level"], + row_value=row["row_value"], + ) + ) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.id != stale.id + entries = await _entries(maker, document.id) + assert [entry.row_number for entry in entries] == [row["row_number"] for row in rows] + async with maker() as session: + total = ( + await session.execute( + select(func.count()) + .select_from(OsvEntry) + .join(Document, Document.id == OsvEntry.document_id) + .where(Document.stored_file_id == file_id) + ) + ).scalar_one() + assert total == len(rows) + + +async def test_two_sheets_of_same_account(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """«Два листа одного счёта»: один документ (первый лист), `NEEDS_CHECK`, предупреждение называет оба `locator`.""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() + first = _osv(result) + second = copy.deepcopy(first) | {"locator": "sheet:Лист_2"} + second["header"]["account_code_raw"] = "60" + result["documents"] = [first, second] + file_id, job_id, _ = await _prepare(maker, storage, result, owner_inn="7700000000") + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.locator == "sheet:Лист_1" + assert document.osv_account_code == "60" + assert document.status is DocumentStatus.NEEDS_CHECK + assert len(await _entries(maker, document.id)) == len(first["rows"]) + job = await _job(maker, job_id) + assert job.metrics[MAPPING_WARNINGS_KEY] == ["sheet=sheet:Лист_2: счёт 60 уже есть в листе sheet:Лист_1"] + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + + +async def test_header_of_another_organization(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """«Шапка другой организации»: файл ЮЛ «Пекарня Счастливочная», в шапке `АО "Русский бисквит"` → `MISMATCH`, + `NEEDS_CHECK`.""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() + _osv(result)["header"] |= {"organization_name": 'АО "Русский бисквит"', "organization_inn": None} + file_id, _, _ = await _prepare(maker, storage, result, owner_name="ООО «Пекарня Счастливочная»") + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.counterparty_check is DocumentCheckResult.MISMATCH + assert document.status is DocumentStatus.NEEDS_CHECK + + +async def _q1_document( + maker: async_sessionmaker[AsyncSession], + entity_id: uuid.UUID, + *, + opening_credit: Decimal, + file_status: FileStatus, +) -> None: + """Документ ОСВ 60 за 2024 Q1 другого файла того же ЮЛ с корнем (level 0) и строкой-потомком.""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, entity_id) + assert entity is not None + file, job = await make_stored_file( + session, entity=entity, status=file_status, job_status=RecognitionStatus.done + ) + document = await make_document(session, file=file, job=job, account="60", year=2024, quarter=PeriodQuarter.Q1) + common = {"recognition_job_id": job.id, "document_id": document.id, "owner_legal_entity_id": entity_id} + root = OsvEntry( + **common, row_number=5, level=0, row_value="60", opening_debit=None, opening_credit=opening_credit + ) + session.add(root) + await session.flush() + # строка уровня 1 с меньшим номером не должна считаться корнем + session.add(OsvEntry(**common, row_number=1, level=1, parent_entry_id=root.id, opening_credit=Decimal(1))) + + +@pytest.mark.parametrize( + ("q1_opening", "q1_file_status", "expected"), + [ + (Decimal("1000.00"), FileStatus.PROCESSED, True), # сальдо на начало совпало с Q1 + (Decimal("1000.01"), FileStatus.PROCESSED, True), # в пределах ±0.01 + (Decimal("999.00"), FileStatus.PROCESSED, False), # не совпало — по тексту + (Decimal("1000.00"), FileStatus.SUPERSEDED, False), # Q1 из заменённого файла не учитывается + ], +) +async def test_cumulative_by_q1_data( + committed_sessionmaker: async_sessionmaker[AsyncSession], + q1_opening: Decimal, + q1_file_status: FileStatus, + expected: bool, +): + """«Нарастающая по данным»: ОСВ 60 «за 3 квартал 2024» с `opening_*` корня = Q1 → `is_cumulative = true` и + предупреждение «нарастающая по данным Q1» (ASSUMPTION(OQ-02)).""" + maker, storage = committed_sessionmaker, FakeStorage() + async with maker() as session, session.begin(): + entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000000") + await _q1_document(maker, entity.id, opening_credit=q1_opening, file_status=q1_file_status) + result = _minimal() + _osv(result)["header"] |= { + "period_text": "3 квартал 2024 г.", + "period_year": 2024, + "period_quarter": "Q3", + "is_cumulative": False, + } + assert _root(_osv(result)["rows"])["opening_credit"] == "1000.00" + file_id, job_id, _ = await _prepare(maker, storage, result, entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.period_quarter is PeriodQuarter.Q3 + assert document.is_cumulative is expected + warnings = (await _job(maker, job_id)).metrics[MAPPING_WARNINGS_KEY] + if expected: + assert len(warnings) == 1 + assert "нарастающая по данным Q1" in warnings[0] + assert "sheet:Лист_1" in warnings[0] + assert document.status is DocumentStatus.NEEDS_CHECK + else: + assert warnings == [] + assert document.status is DocumentStatus.RECOGNIZED + + +@pytest.mark.parametrize( + ("job_status", "expected"), + [(RecognitionStatus.done, DocumentStatus.RECOGNIZED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)], +) +async def test_clean_result( + committed_sessionmaker: async_sessionmaker[AsyncSession], + job_status: RecognitionStatus, + expected: DocumentStatus, +): + """«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`; + задание `needs_check` → `NEEDS_CHECK`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.status is expected + assert document.counterparty_check is DocumentCheckResult.MATCH + assert document.period_check is DocumentCheckResult.UNKNOWN + assert document.osv_account_code == "60" + assert document.period_quarter is PeriodQuarter.Q1 + assert document.is_cumulative is False + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + + +async def test_counterparty_resolved_in_entries(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """R-3: `counterparty_id` — только у `COUNTERPARTY` с `data_type ∈ {BU, SUM}`; `NU` хранится как пришёл, без + резолва; строка-счёт без контрагента.""" + maker, storage = committed_sessionmaker, FakeStorage() + async with maker() as session, session.begin(): + lyutik = await make_legal_entity(session, name="ООО «Лютик»", inn="7711111111") + result = _minimal() + rows = _osv(result)["rows"] + rows.append(rows[1] | {"row_number": 3, "data_type": "NU"}) + file_id, _, _ = await _prepare(maker, storage, result) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + account, bu, nu = await _entries(maker, document.id) + assert account.group_type is OsvGroupType.ACCOUNT + assert account.counterparty_id is None + assert bu.counterparty_id == lyutik.id + assert bu.counterparty_inn == "7711111111" + assert nu.data_type is OsvDataType.NU + assert nu.counterparty_id is None + + +async def test_result_of_non_current_job_is_ignored(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Review Focus 1: результат задания, которое уже не текущее для файла, не выбирается и файл не трогает.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, old_job_id, _ = await _prepare(maker, storage, _minimal()) + async with maker() as session, session.begin(): + file = await session.get(StoredFile, file_id) + assert file is not None + new_job = RecognitionJob( + file_id=file_id, + pipeline=(await session.get(RecognitionJob, old_job_id)).pipeline, # type: ignore[union-attr] + request_subject="recognition.tabular.request", + schema_version="1.0", + status=RecognitionStatus.processing, + attempt=2, + ) + session.add(new_job) + await session.flush() + file.current_recognition_job_id = new_job.id + before = await _file(maker, file_id) + + assert await process_once(maker, storage, _settings()) is False + + after = await _file(maker, file_id) + assert (after.status, after.updated_at) == (FileStatus.PROCESSING, before.updated_at) + assert await _documents(maker, file_id) == [] + assert (await _job(maker, old_job_id)).group_schema is None + + +async def test_job_replaced_while_reading_result( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """Review Focus 1, гонка: пока цикл читал S3, у файла сменилось текущее задание → транзакция маппинга ничего не + меняет (задание и файл перечитываются под блокировкой).""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + real_get_bytes = storage.get_bytes + + async def get_bytes_and_replace_job(key: str) -> bytes: + async with maker() as session, session.begin(): + file = await session.get(StoredFile, file_id) + job = await session.get(RecognitionJob, job_id) + assert file is not None and job is not None + new_job = RecognitionJob( + file_id=file_id, + pipeline=job.pipeline, + request_subject=job.request_subject, + schema_version="1.0", + status=RecognitionStatus.queued, + attempt=2, + ) + session.add(new_job) + await session.flush() + file.current_recognition_job_id = new_job.id + return await real_get_bytes(key) + + monkeypatch.setattr(storage, "get_bytes", get_bytes_and_replace_job) + + assert await process_once(maker, storage, _settings()) is True + + file = await _file(maker, file_id) + assert file.status is FileStatus.PROCESSING + assert await _documents(maker, file_id) == [] + job = await _job(maker, job_id) + assert job.group_schema is None + assert MAPPING_ATTEMPTS_KEY not in (job.metrics or {}) + + +async def test_run_mapping_loop_processes_two_candidates(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """`run_mapping_loop`: два кандидата подряд без паузы (`sleep(0)`), затем пауза `MAPPING_POLL_SECONDS`.""" + maker, storage = committed_sessionmaker, FakeStorage() + first, _, _ = await _prepare(maker, storage, _minimal()) + second, _, _ = await _prepare(maker, storage, _minimal()) + settings = _settings() + sleeps: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + if len(sleeps) == 3: # noqa: PLR2004 - два кандидата и один пустой проход + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await run_mapping_loop(maker, storage, settings, sleep=fake_sleep) + + assert sleeps == [0, 0, settings.MAPPING_POLL_SECONDS] + assert (await _file(maker, first)).status is FileStatus.PROCESSED + assert (await _file(maker, second)).status is FileStatus.PROCESSED + + +async def test_run_mapping_loop_survives_errors(monkeypatch: pytest.MonkeyPatch): + """Ошибка прохода логируется, цикл не умирает и ждёт `MAPPING_POLL_SECONDS`.""" + outcomes: list[Callable[[], bool]] = [lambda: (_ for _ in ()).throw(RuntimeError("сбой")), lambda: True] + sleeps: list[float] = [] + + async def fake_process_once(*args: Any, **kwargs: Any) -> bool: + return outcomes.pop(0)() + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + if not outcomes: + raise asyncio.CancelledError + + monkeypatch.setattr(rm, "process_once", fake_process_once) + settings = _settings() + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="ERROR") + try: + with pytest.raises(asyncio.CancelledError): + await run_mapping_loop(None, FakeStorage(), settings, sleep=fake_sleep) # type: ignore[arg-type] + finally: + logger.remove(handler_id) + + assert sleeps == [settings.MAPPING_POLL_SECONDS, 0] + assert len(records) == 1 diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index c94a3f2..fa82bfd 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -228,9 +228,9 @@ async def run_mapping_loop(session_factory, storage, settings, *, sleep=asyncio. ``` `ExchangeTasks.start(session_factory, nats, settings, storage)` — третья задача `recognition-result-mapper` только при `storage is not None` (иначе warning «S3 не подключено: маппинг результатов не запущен»). -- [ ] **Step 1: Тесты** (integration, `committed_sessionmaker` + `FakeStorage`; результат — golden JSON `ОСВ_66 за 2024.json` с подставленными `job_id`/`file_id`): сценарии спеки `osv-result-mapping` «Результат смапплен» (документ 66/2024/Q4/cumulative, `locator`, 9 строк (10 строк снимка минус TOTAL), `group_schema`), «Ключ вне папки» → `ERROR`, «Сбой с повтором» (monkeypatch `map_file` бросает 2 раза → attempts, 3-й → `ERROR`), «Идемпотентность» (вставить половину строк вручную → `process_once` → ровно один набор), «Два листа одного счёта» (JSON с двумя документами `60`) → один документ `NEEDS_CHECK` + warning, «Шапка другой организации» → `MISMATCH`/`NEEDS_CHECK`, «Нарастающая по данным» (сначала документ Q1 через фабрики с корнем, затем Q3 с теми же opening) → `is_cumulative = True`, «Чистый результат» → `RECOGNIZED`; результат без документов (`status: error`-подобный JSON с `documents: []` не валиден по контракту → fatal) → `ERROR` (Review Focus 2); результат для задания, которое уже не текущее → не трогает файл (Review Focus 1); `run_mapping_loop` с заглушкой sleep обрабатывает два кандидата подряд. -- [ ] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_result_mapping.py -q`, `make check`. -- [ ] **Step 5: Commit** `feat(mapping): фоновый цикл маппинга результата в document/osv_entry (OQ-69, OQ-70)`. +- [x] **Step 1: Тесты** (integration, `committed_sessionmaker` + `FakeStorage`; результат — golden JSON `ОСВ_66 за 2024.json` с подставленными `job_id`/`file_id`): сценарии спеки `osv-result-mapping` «Результат смапплен» (документ 66/2024/Q4/cumulative, `locator`, 9 строк (10 строк снимка минус TOTAL), `group_schema`), «Ключ вне папки» → `ERROR`, «Сбой с повтором» (monkeypatch `map_file` бросает 2 раза → attempts, 3-й → `ERROR`), «Идемпотентность» (вставить половину строк вручную → `process_once` → ровно один набор), «Два листа одного счёта» (JSON с двумя документами `60`) → один документ `NEEDS_CHECK` + warning, «Шапка другой организации» → `MISMATCH`/`NEEDS_CHECK`, «Нарастающая по данным» (сначала документ Q1 через фабрики с корнем, затем Q3 с теми же opening) → `is_cumulative = True`, «Чистый результат» → `RECOGNIZED`; результат без документов (`status: error`-подобный JSON с `documents: []` не валиден по контракту → fatal) → `ERROR` (Review Focus 2); результат для задания, которое уже не текущее → не трогает файл (Review Focus 1); `run_mapping_loop` с заглушкой sleep обрабатывает два кандидата подряд. +- [x] **Step 2–4:** падает → реализация → `pytest -m "integration or fixtures" tests/test_result_mapping.py -q`, `make check`. +- [x] **Step 5: Commit** `feat(mapping): фоновый цикл маппинга результата в document/osv_entry (OQ-69, OQ-70)`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index fe6bd91..be4c2bc 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -35,7 +35,7 @@ `repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None). Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json` и golden `ОСВ_66 за 2024`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа. -- [ ] 3.2 **Цикл маппинга.** `services/result_mapping.py`: `pick_candidate` (FOR UPDATE SKIP LOCKED), проверка ключа (OQ-70), чтение +- [x] 3.2 **Цикл маппинга.** `services/result_mapping.py`: `pick_candidate` (FOR UPDATE SKIP LOCKED), проверка ключа (OQ-70), чтение S3 вне транзакции, `map_file` (одна транзакция: очистка прежних строк файла, `document`×N с обработкой повтора счёта, пакетная вставка `osv_entry`, `group_schema`, `is_cumulative` по Q1 (D-9), `metrics.mapping_warnings`, `PROCESSED`), повторы `mapping_attempts` → `ERROR`, `run_mapping_loop` в lifespan (`ExchangeTasks`). Интеграционные тесты: сценарии спеки -- 2.47.2 From ecdf803bd4837113cdc31de33505ea32621251f4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:52:03 +0400 Subject: [PATCH 16/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20Task=207=20(design=20=E2=84=9628=E2=80=933?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index f6b29bb..2810ff6 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -185,3 +185,7 @@ rows без TOTAL, у каждого не-корня есть родитель, | 25 | 2026-10-05 | R-12 (Task 5): корень листа — первая строка `level == 0` без родителя (в голден-снимках ровно один; в двух пустых листах корня нет, `root = None`); имя шапки, пустое после `normalize_alias` (только ОПФ/пунктуация), без ИНН → `counterparty_check = UNKNOWN`; ИНН шапки сравнивается по цифрам; допуск `opening_matches` ±0.01 включительно по дебету и кредиту | шапка без идентифицирующего имени ничего не доказывает и не должна давать ложный MISMATCH | вернуть MISMATCH для пустой нормализованной шапки | | 26 | 2026-10-05 | R-13: «20 строк» в плане (golden `ОСВ_66 за 2024`) исправлено на 9 (10 строк снимка минус TOTAL); критерий — «строк результата − 1» | в снимке 10 строк | нет | | 27 | 2026-10-05 | R-14 (Task 6): ИНН ищется точно после `normalize_inn` (пробелы), мусорные значения проваливаются к имени; пустая нормализация (имя из одних ОПФ) не попадает в индекс; при единственном точном совпадении имени ЮЛ побеждает, даже если нормализованное имя неоднозначно (неоднозначность оценивается по шагам, D-8) | буквальное чтение §3 п. 4 «на любом шаге > 1 → NULL» | поменять приоритет шагов | +| 28 | 2026-10-05 | R-15 (Task 7): после повторяемой неудачи маппинга цикл спит `MAPPING_POLL_SECONDS` (`process_once` возвращает False), а не крутит попытки без паузы | иначе 3 попытки за миллисекунды превращают кратковременный сбой S3/БД в ERROR всех файлов | вернуть мгновенный повтор / задержка в metrics | +| 29 | 2026-10-05 | R-16 (Task 7): статус документа считается по предупреждениям его листа (повтор счёта, сироты, нарастающая по Q1); `metrics.mapping_warnings` — общий список файла | предупреждение одного листа не должно ронять чистый лист другого счёта | передавать весь список | +| 30 | 2026-10-05 | R-17 (Task 7): «нарастающая по данным Q1» даёт предупреждение маппинга, поэтому документ `NEEDS_CHECK` (буквально D-7/D-9: `RECOGNIZED` только без предупреждений) | спека; неопределённая нарастающая требует взгляда аналитика | исключить это предупреждение из правила статуса | +| 31 | 2026-10-05 | R-18 (Task 7): результат с `job_id`/`file_id` ≠ заданию — неповторяемая ошибка; ошибки S3, кроме «нет объекта», — повторяемые; нелистовые документы (BALANCE/INCOME/OTHER) игнорируются без предупреждения; сироты → предупреждение + `NEEDS_CHECK`; ключ с сегментами `.`/`..`/пустыми отвергается | защита от подмены; ОСВ-only подсрез | убрать проверки | -- 2.47.2 From 67e255d6c2bf493db8c8ff4e16fefea5235ac25f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:54:11 +0400 Subject: [PATCH 17/35] =?UTF-8?q?fix(mapping):=20=D0=BF=D0=B0=D1=83=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=D0=BC=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-15 (design №28, находка T7.1): после повторяемой неудачи process_once возвращает False — run_mapping_loop спит MAPPING_POLL_SECONDS перед следующей попыткой; кратковременный сбой S3/БД больше не сжигает все MAPPING_MAX_ATTEMPTS за миллисекунды. Неповторяемая ошибка, исчерпание попыток (файл ERROR) и успешный маппинг — по-прежнему True. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/result_mapping.py | 30 +++++++++++++++---------- api/tests/test_result_mapping.py | 36 +++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 63cb0a2..4ec14fa 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -15,9 +15,9 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции -`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`. Статус файла — только через -`services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг не аудируются -(ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — отдельно. +`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). +Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг +не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — отдельно. """ import asyncio @@ -456,12 +456,13 @@ async def _register_failure( *, max_attempts: int, now: datetime, -) -> None: - """D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR`.""" +) -> bool: + """D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR`. + Возвращает `True`, если попытки исчерпаны (файл переведён в `ERROR` и покинул очередь).""" async with session_factory() as session, session.begin(): job = await _lock_job(session, candidate.job_id) if job is None: - return + return False metrics = _metrics(job) attempts = _attempts(metrics) + 1 metrics[MAPPING_ATTEMPTS_KEY] = attempts @@ -482,6 +483,7 @@ async def _register_failure( attempts=attempts, exhausted=exhausted, ) + return exhausted async def _map_candidate( @@ -524,8 +526,11 @@ async def process_once( *, now: Clock | None = None, ) -> bool: - """Один проход цикла маппинга (модель шагов — в docstring модуля). `False` — кандидатов нет; `True` — кандидат - обработан (смапплен, отклонён, отложен до следующей попытки или пропущен, потому что его опередили).""" + """Один проход цикла маппинга (модель шагов — в docstring модуля). Результат — выполнена ли полезная работа, после + которой цикл может не спать: `True` — кандидат смапплен, отклонён неповторяемой ошибкой или исчерпал попытки + (файл `ERROR`), либо пропущен, потому что его опередили; `False` — кандидатов нет или повторяемая неудача + (R-15: пауза `MAPPING_POLL_SECONDS` перед следующей попыткой, иначе кратковременный сбой S3/БД сжёг бы все попытки + за миллисекунды).""" clock = now or _utcnow async with session_factory() as session, session.begin(): candidate = await pick_candidate(session, max_attempts=settings.MAPPING_MAX_ATTEMPTS) @@ -543,8 +548,8 @@ async def process_once( except MappingFatal as exc: async with session_factory() as session, session.begin(): await _fail(session, candidate, str(exc), clock()) - except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор в следующем проходе - await _register_failure( + except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор после паузы цикла (R-15) + return await _register_failure( session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock() ) return True @@ -557,8 +562,9 @@ async def run_mapping_loop( *, sleep: SleepFn = asyncio.sleep, ) -> None: - """Бесконечный цикл маппинга: после обработанного кандидата — следующий сразу, без кандидатов — пауза - `MAPPING_POLL_SECONDS`; ошибка прохода логируется, цикл продолжается.""" + """Бесконечный цикл маппинга: после полезной работы (`process_once` → `True`) — следующий кандидат сразу; без + кандидатов или после повторяемой неудачи — пауза `MAPPING_POLL_SECONDS` (R-15); ошибка прохода логируется, цикл + продолжается.""" while True: processed = False try: diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 14e1854..2fa6397 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -355,13 +355,15 @@ async def test_mapping_failure_retries_then_error( monkeypatch.setattr(rm, "map_file", failing_map_file) for attempt in (1, 2): - assert await process_once(maker, storage, _settings()) is True + # R-15: повторяемая неудача — `False`, цикл выдерживает паузу MAPPING_POLL_SECONDS до следующей попытки + assert await process_once(maker, storage, _settings()) is False job = await _job(maker, job_id) assert job.metrics[MAPPING_ATTEMPTS_KEY] == attempt assert MAPPING_ERROR_KEY not in job.metrics assert (await _file(maker, file_id)).status is FileStatus.PROCESSING assert await _documents(maker, file_id) == [] + # третья неудача переводит файл в ERROR: кандидат покинул очередь, следующий можно брать без паузы — `True` assert await process_once(maker, storage, _settings()) is True job = await _job(maker, job_id) assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию @@ -663,6 +665,38 @@ async def test_run_mapping_loop_processes_two_candidates(committed_sessionmaker: assert (await _file(maker, second)).status is FileStatus.PROCESSED +async def test_run_mapping_loop_pauses_after_retryable_failure( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """R-15: после повторяемой неудачи цикл спит `MAPPING_POLL_SECONDS` (попытки не сгорают за миллисекунды), после + успешного маппинга — `sleep(0)`.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + real_map_file = rm.map_file + failures = [RuntimeError("кратковременный сбой БД")] + + async def flaky_map_file(session: AsyncSession, **kwargs: Any) -> list[str]: + if failures: + raise failures.pop() + return await real_map_file(session, **kwargs) + + monkeypatch.setattr(rm, "map_file", flaky_map_file) + settings = _settings() + sleeps: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + if len(sleeps) == 3: # noqa: PLR2004 - неудача, успех, пустой проход + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await run_mapping_loop(maker, storage, settings, sleep=fake_sleep) + + assert sleeps == [settings.MAPPING_POLL_SECONDS, 0, settings.MAPPING_POLL_SECONDS] + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + assert (await _job(maker, job_id)).metrics[MAPPING_ATTEMPTS_KEY] == 1 + + async def test_run_mapping_loop_survives_errors(monkeypatch: pytest.MonkeyPatch): """Ошибка прохода логируется, цикл не умирает и ждёт `MAPPING_POLL_SECONDS`.""" outcomes: list[Callable[[], bool]] = [lambda: (_ for _ in ()).throw(RuntimeError("сбой")), lambda: True] -- 2.47.2 From b62cec83ba8e3522d3da146dfef510b58564e75d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:06:37 +0400 Subject: [PATCH 18/35] =?UTF-8?q?feat(mapping):=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=BD=D0=B8=D1=85=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20(FILE=5FR?= =?UTF-8?q?EPLACE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supersede_previous` вызывается в `map_file` после `PROCESSED`, в той же транзакции (D-10): файлы того же ЮЛ (не SUPERSEDED, не сам новый) с документом тех же type/счёт/год/квартал → SUPERSEDED, `superseded_by_file_id`, `updated_at`; новому — `version_no` = max + 1; одно системное событие `FILE_REPLACE` на заменённый файл (user_id NULL, инициатор загрузки, перечень документов и `confirmed=true|false` в description). Документы и строки заменённых файлов сохраняются. Автозамены одного ЮЛ сериализуются advisory-блокировкой, заменяемые файлы блокируются FOR UPDATE в порядке id, документы перечитываются после блокировки. Тесты: сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (CONFIRMED и RECOGNIZED), другой счёт/год/квартал, другое ЮЛ, идемпотентность после сбоя, цепочка v1→v2→v3, одно событие на файл. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/result_mapping.py | 137 ++++++++- api/tests/test_result_mapping.py | 260 +++++++++++++++++- .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 4 files changed, 395 insertions(+), 10 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 4ec14fa..21439c0 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -11,13 +11,14 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn 3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, - документы и строки, `group_schema`, предупреждения, `PROCESSED`. + документы и строки, `group_schema`, предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, + D-10, аудит `FILE_REPLACE`). Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг -не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — отдельно. +не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`). """ import asyncio @@ -31,7 +32,7 @@ from typing import Any from loguru import logger from pydantic import ValidationError -from sqlalchemy import Integer, delete, func, insert, select +from sqlalchemy import Integer, Select, delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult @@ -48,7 +49,10 @@ from src.domain.osv_mapping import ( from src.models.accounting import ChartOfAccount from src.models.directories import LegalEntity from src.models.enums import ( + AuditAction, + AuditSection, DocumentCheckResult, + DocumentStatus, DocumentType, FileStatus, OsvDataType, @@ -59,6 +63,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.repositories.counterparties import CounterpartyIndex +from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job __all__ = [ @@ -72,6 +77,7 @@ __all__ = [ "pick_candidate", "process_once", "run_mapping_loop", + "supersede_previous", "validate_result_key", ] @@ -85,6 +91,8 @@ MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check) CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3 ROOT_LEVEL = 0 +STORED_FILE_OBJECT_TYPE = "stored_file" # `audit_event.object_type` события FILE_REPLACE +SUPERSEDE_LOCK_PREFIX = "supersede" # пространство ключей advisory-блокировки автозамены в пределах ЮЛ _FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."}) Clock = Callable[[], datetime] @@ -315,11 +323,11 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай index: CounterpartyIndex, now: datetime, ) -> list[str]: - """Маппинг результата в транзакции вызывающего (D-3, D-6…D-9); возвращает предупреждения маппинга файла. + """Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла. Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из - `PROCESSING` и только для текущего задания). Без commit. + `PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit. """ sheets = _osv_documents(result) if not sheets: @@ -408,9 +416,128 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай ): raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее") await session.refresh(file, ["status", "updated_at"]) + await supersede_previous(session, new_file=file, new_documents=documents, uploader_id=file.uploaded_by, now=now) return warnings +# --- автозамена прежних версий -------------------------------------------------------------------------------------- + + +DocumentKey = tuple[DocumentType, str | None, int, PeriodQuarter] + + +def _document_key(document: Document) -> DocumentKey | None: + """Ключ совпадения документов для автозамены (D-10); документ без года или квартала в замене не участвует.""" + if document.period_year is None or document.period_quarter is None: + return None + return (document.document_type, document.osv_account_code, document.period_year, document.period_quarter) + + +def _describe_document(document: Document) -> str: + period = f"{document.period_year} {document.period_quarter.value if document.period_quarter else ''}".strip() + return f"{document.document_type.value} {document.osv_account_code or ''} {period} ({document.status.value})" + + +async def supersede_previous( + session: AsyncSession, + *, + new_file: StoredFile, + new_documents: Sequence[Document], + uploader_id: uuid.UUID | None, + now: datetime, +) -> list[StoredFile]: + """D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с + документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`, + `superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1. На каждый + заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор загрузки — в `description`, + `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и + строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit. + + Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные + параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в + порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`). + """ + keys = {key for key in map(_document_key, new_documents) if key is not None} + if not keys: + return [] + lock_key = func.hashtextextended(f"{SUPERSEDE_LOCK_PREFIX}:{new_file.legal_entity_id}", 0) + await session.execute(select(func.pg_advisory_xact_lock(lock_key))) + + def matching(stmt: Select[tuple[Document]]) -> Select[tuple[Document]]: + return stmt.join(StoredFile, StoredFile.id == Document.stored_file_id).where( + StoredFile.legal_entity_id == new_file.legal_entity_id, + StoredFile.id != new_file.id, + StoredFile.status != FileStatus.SUPERSEDED, + Document.document_type.in_(sorted({key[0] for key in keys})), + Document.period_year.in_(sorted({key[2] for key in keys})), + ) + + candidates = (await session.execute(matching(select(Document)))).scalars().all() + file_ids = sorted({document.stored_file_id for document in candidates if _document_key(document) in keys}) + if not file_ids: + return [] + locked = ( + ( + await session.execute( + select(StoredFile) + .where(StoredFile.id.in_(file_ids), StoredFile.status != FileStatus.SUPERSEDED) + .order_by(StoredFile.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + ) + .scalars() + .all() + ) + if not locked: + return [] + ids = [old.id for old in locked] + # документы перечитываются ПОСЛЕ блокировки файлов: подтверждение, зафиксированное до неё, попадёт в `confirmed` + fresh = await session.execute( + matching(select(Document)).where(Document.stored_file_id.in_(ids)).execution_options(populate_existing=True) + ) + replaced_documents: dict[uuid.UUID, list[Document]] = {} + for document in fresh.scalars(): + if _document_key(document) in keys: + replaced_documents.setdefault(document.stored_file_id, []).append(document) + await session.execute( + update(StoredFile) + .where(StoredFile.id.in_(ids), StoredFile.status != FileStatus.SUPERSEDED) + .values(status=FileStatus.SUPERSEDED, superseded_by_file_id=new_file.id, updated_at=now) + .execution_options(synchronize_session=False) + ) + new_file.version_no = max(old.version_no for old in locked) + 1 + new_file.updated_at = now + await session.flush() + + audit = AuditService(session) + uploader = f"user:{uploader_id}" if uploader_id is not None else "неизвестен" + for old in locked: + await session.refresh(old, ["status", "superseded_by_file_id", "updated_at"]) + documents = sorted(replaced_documents.get(old.id, []), key=_describe_document) + confirmed = any(document.status is DocumentStatus.CONFIRMED for document in documents) + await audit.record( + action=AuditAction.FILE_REPLACE, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=STORED_FILE_OBJECT_TYPE, + object_id=old.id, + user_id=None, + after_value={"file_id": new_file.id, "version_no": new_file.version_no}, + description=( + f"заменён файлом {new_file.original_name} (загрузил {uploader}); " + f"документы: {', '.join(map(_describe_document, documents))}; " + f"confirmed={'true' if confirmed else 'false'}" + ), + ) + logger.info( + "Автозамена: прежние версии файла заменены. file_id={file_id}, version_no={version_no}, replaced={replaced}", + file_id=new_file.id, + version_no=new_file.version_no, + replaced=[str(old_id) for old_id in ids], + ) + return list(locked) + + # --- проход и цикл -------------------------------------------------------------------------------------------------- diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 2fa6397..0f76fa8 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -24,9 +24,13 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.core.config import Settings, get_settings +from src.models.access import AppUser from src.models.accounting import ChartOfAccount +from src.models.audit_ex import AuditEvent from src.models.directories import LegalEntity from src.models.enums import ( + AuditAction, + AuditSection, DocumentCheckResult, DocumentStatus, DocumentType, @@ -47,6 +51,7 @@ from src.services.result_mapping import ( MappingFatal, process_once, run_mapping_loop, + supersede_previous, validate_result_key, ) from tests.conftest import FakeStorage @@ -104,13 +109,16 @@ async def _prepare( # noqa: PLR0913 - каждое поле подготовк job_status: RecognitionStatus = RecognitionStatus.done, file_status: FileStatus = FileStatus.PROCESSING, key: str | None = None, + uploaded_by: uuid.UUID | None = None, ) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: """ЮЛ-владелец, файл `PROCESSING` с текущим заданием `done`, результат в `storage`; `(file_id, job_id, entity_id)`. `job_id`/`file_id` результата подставляются; `key` — подменить ключ `s3_object` результата (OQ-70).""" async with maker() as session, session.begin(): owner = entity or await make_legal_entity(session, name=owner_name, inn=owner_inn) - file, job = await make_stored_file(session, entity=owner, status=file_status, job_status=job_status) + file, job = await make_stored_file( + session, entity=owner, status=file_status, job_status=job_status, uploaded_by=uploaded_by + ) job.finished_at = _now() job.metrics = copy.deepcopy(result.get("metrics")) # как inbox: метрики сервиса из конверта payload = copy.deepcopy(result) | {"job_id": str(job.id), "file_id": str(file.id)} @@ -722,3 +730,253 @@ async def test_run_mapping_loop_survives_errors(monkeypatch: pytest.MonkeyPatch) assert sleeps == [settings.MAPPING_POLL_SECONDS, 0] assert len(records) == 1 + + +# --- автозамена прежних версий (spec primary-files «Автозамена прежних версий», audit-trail; design D-10) ---------- + +OldDocument = tuple[str, int, PeriodQuarter, DocumentStatus] # счёт, год, квартал, статус документа прежнего файла +OSV_60_2025_Q1: OldDocument = ("60", 2025, PeriodQuarter.Q1, DocumentStatus.CONFIRMED) + + +async def _old_file( # noqa: PLR0913 - каждое поле прежнего файла задаётся тестом явно + maker: async_sessionmaker[AsyncSession], + entity_id: uuid.UUID, + documents: list[OldDocument], + *, + version_no: int = 1, + status: FileStatus = FileStatus.PROCESSED, + superseded_by: uuid.UUID | None = None, +) -> uuid.UUID: + """Прежний файл ЮЛ с документами ОСВ и строкой ОСВ на каждый документ (история должна сохраниться).""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, entity_id) + assert entity is not None + file, job = await make_stored_file( + session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name="old.xls" + ) + file.version_no = version_no + file.superseded_by_file_id = superseded_by + for account, year, quarter, document_status in documents: + document = await make_document( + session, file=file, job=job, account=account, year=year, quarter=quarter, status=document_status + ) + session.add( + OsvEntry( + recognition_job_id=job.id, + document_id=document.id, + owner_legal_entity_id=entity_id, + row_number=1, + level=0, + row_value=account, + ) + ) + return file.id + + +async def _entity( + maker: async_sessionmaker[AsyncSession], name: str = OWNER_NAME, inn: str = "7700000000" +) -> uuid.UUID: + async with maker() as session, session.begin(): + return (await make_legal_entity(session, name=name, inn=inn)).id + + +async def _any_user(maker: async_sessionmaker[AsyncSession]) -> uuid.UUID: + async with maker() as session: + return (await session.execute(select(AppUser.id).order_by(AppUser.email).limit(1))).scalar_one() + + +async def _replace_events(maker: async_sessionmaker[AsyncSession]) -> list[AuditEvent]: + async with maker() as session: + rows = await session.execute( + select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_REPLACE).order_by(AuditEvent.occurred_at) + ) + return list(rows.scalars()) + + +async def _active_files(maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID) -> set[uuid.UUID]: + """Файлы ЮЛ, которые список файлов показывает по умолчанию (без `include_superseded`).""" + async with maker() as session: + rows = await session.execute( + select(StoredFile.id).where( + StoredFile.legal_entity_id == entity_id, StoredFile.status != FileStatus.SUPERSEDED + ) + ) + return set(rows.scalars()) + + +@pytest.mark.parametrize( + ("old_status", "confirmed"), + [(DocumentStatus.CONFIRMED, "confirmed=true"), (DocumentStatus.RECOGNIZED, "confirmed=false")], +) +async def test_new_osv_60_supersedes_previous_file( + committed_sessionmaker: async_sessionmaker[AsyncSession], old_status: DocumentStatus, confirmed: str +): + """«Новая ОСВ 60 за 1 кв. 2025»: прежний файл (документ `CONFIRMED` тоже, решение владельца 05.10.2026) — + `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `FILE_REPLACE` — системное событие + (`user_id` NULL, инициатор загрузки — в `description`); документы и строки прежнего файла сохранены.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + uploader = await _any_user(maker) + old_id = await _old_file(maker, entity_id, [("60", 2025, PeriodQuarter.Q1, old_status)]) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=uploader) + + assert await process_once(maker, storage, _settings()) is True + + old, new = await _file(maker, old_id), await _file(maker, new_id) + assert old.status is FileStatus.SUPERSEDED + assert old.superseded_by_file_id == new_id + assert old.updated_at is not None + assert (new.status, new.version_no, new.superseded_by_file_id) == (FileStatus.PROCESSED, 2, None) + assert await _active_files(maker, entity_id) == {new_id} + [old_document] = await _documents(maker, old_id) # история: документ и строки прежнего файла на месте + assert old_document.status is old_status + assert len(await _entries(maker, old_document.id)) == 1 + + [event] = await _replace_events(maker) + assert event.user_id is None + assert event.section is AuditSection.PRIMARY_DOCUMENTS + assert (event.object_type, event.object_id) == ("stored_file", old_id) + assert event.after_value == {"file_id": str(new_id), "version_no": 2} + assert event.description is not None + assert f"user:{uploader}" in event.description + assert f"OSV 60 2025 Q1 ({old_status.value})" in event.description + assert confirmed in event.description + + +@pytest.mark.parametrize( + "document", + [ + ("62", 2025, PeriodQuarter.Q1, DocumentStatus.CONFIRMED), # другой счёт + ("60", 2024, PeriodQuarter.Q1, DocumentStatus.CONFIRMED), # тот же счёт, другой год + ("60", 2025, PeriodQuarter.Q2, DocumentStatus.CONFIRMED), # тот же счёт, другой квартал + ], +) +async def test_other_document_is_not_superseded( + committed_sessionmaker: async_sessionmaker[AsyncSession], document: OldDocument +): + """Два разных счёта (или периода) не заменяют друг друга: прежний файл `PROCESSED`, у нового `version_no = 1`.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + old_id = await _old_file(maker, entity_id, [document]) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + old, new = await _file(maker, old_id), await _file(maker, new_id) + assert (old.status, old.superseded_by_file_id) == (FileStatus.PROCESSED, None) + assert (new.status, new.version_no) == (FileStatus.PROCESSED, 1) + assert await _replace_events(maker) == [] + + +async def test_other_legal_entity_is_not_superseded(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Тот же документ у другого ЮЛ не затрагивается: замена — только в пределах ЮЛ-владельца.""" + maker, storage = committed_sessionmaker, FakeStorage() + other_id = await _entity(maker, name="ООО «Другое ЮЛ»", inn="7799999999") + old_id = await _old_file(maker, other_id, [OSV_60_2025_Q1]) + new_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000") + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, old_id)).status is FileStatus.PROCESSED + assert (await _file(maker, new_id)).version_no == 1 + assert await _replace_events(maker) == [] + + +async def test_supersede_is_idempotent( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """Сбой после автозамены откатывает и замену, и аудит; повтор маппинга даёт ровно одно `FILE_REPLACE`. Повторный + вызов `supersede_previous` для того же файла ничего не находит (заменённый уже `SUPERSEDED`).""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + old_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1]) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + real_map_file = rm.map_file + failures = [RuntimeError("сбой БД после автозамены")] + + async def flaky_map_file(session: AsyncSession, **kwargs: Any) -> list[str]: + warnings = await real_map_file(session, **kwargs) + if failures: + raise failures.pop() + return warnings + + monkeypatch.setattr(rm, "map_file", flaky_map_file) + + assert await process_once(maker, storage, _settings()) is False # повторяемая неудача: всё откачено + assert (await _file(maker, old_id)).status is FileStatus.PROCESSED + assert await _replace_events(maker) == [] + + assert await process_once(maker, storage, _settings()) is True + assert (await _file(maker, old_id)).status is FileStatus.SUPERSEDED + assert len(await _replace_events(maker)) == 1 + + async with maker() as session, session.begin(): + new = await session.get(StoredFile, new_id) + assert new is not None + documents = list((await session.execute(select(Document).where(Document.stored_file_id == new_id))).scalars()) + replaced = await supersede_previous( + session, new_file=new, new_documents=documents, uploader_id=None, now=_now() + ) + assert replaced == [] + assert len(await _replace_events(maker)) == 1 + assert (await _file(maker, new_id)).version_no == 2 # noqa: PLR2004 - версия не растёт от повторного вызова + + +async def test_version_chain(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Цепочка v1 → v2 → v3: третий файл заменяет только действующий v2 и получает `version_no = 3`; v1 остаётся + заменённым файлом v2.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + v2_id = await _old_file(maker, entity_id, [OSV_60_2025_Q1], version_no=2) + v1_id = await _old_file( + maker, entity_id, [OSV_60_2025_Q1], version_no=1, status=FileStatus.SUPERSEDED, superseded_by=v2_id + ) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + v3_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + v1, v2, v3 = await _file(maker, v1_id), await _file(maker, v2_id), await _file(maker, v3_id) + assert (v1.status, v1.superseded_by_file_id) == (FileStatus.SUPERSEDED, v2_id) + assert (v2.status, v2.superseded_by_file_id) == (FileStatus.SUPERSEDED, v3_id) + assert v3.version_no == 3 # noqa: PLR2004 - третья версия + [event] = await _replace_events(maker) + assert event.object_id == v2_id + assert event.after_value == {"file_id": str(v3_id), "version_no": 3} + + +async def test_one_event_per_replaced_file(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Новый файл с ОСВ 60 и 62 заменяет прежний файл с теми же двумя документами одним событием `FILE_REPLACE`, + в `description` — оба документа.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + old_id = await _old_file( + maker, + entity_id, + [OSV_60_2025_Q1, ("62", 2025, PeriodQuarter.Q1, DocumentStatus.NEEDS_CHECK)], + ) + result = _minimal() + second = copy.deepcopy(_osv(result)) | {"locator": "sheet:Лист_2"} + second["header"]["account_code_raw"] = "62" + result["documents"].append(second) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, result, entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, old_id)).superseded_by_file_id == new_id + assert len(await _documents(maker, new_id)) == 2 # noqa: PLR2004 - два листа, два счёта + [event] = await _replace_events(maker) + assert event.object_id == old_id + assert event.description is not None + assert "OSV 60 2025 Q1 (CONFIRMED)" in event.description + assert "OSV 62 2025 Q1 (NEEDS_CHECK)" in event.description + assert "confirmed=true" in event.description diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index fa82bfd..2bb16d4 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -247,9 +247,9 @@ async def supersede_previous(session, *, new_file: StoredFile, new_documents: Se ``` Вызывается в `map_file` после установки `PROCESSED`, в той же транзакции. -- [ ] **Step 1: Тесты:** сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (старый `CONFIRMED` → `SUPERSEDED`, `version_no = 2`, аудит с `confirmed=true`); два разных счёта не заменяют друг друга; тот же счёт другого года не заменяет; заменённый файл не появляется в `GET files` без `include_superseded` (проверяется в Task 9, здесь — по БД). -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)`. +- [x] **Step 1: Тесты:** сценарий спеки «Новая ОСВ 60 за 1 кв. 2025» (старый `CONFIRMED` → `SUPERSEDED`, `version_no = 2`, аудит с `confirmed=true`); два разных счёта не заменяют друг друга; тот же счёт другого года не заменяет; заменённый файл не появляется в `GET files` без `include_superseded` (проверяется в Task 9, здесь — по БД). +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(mapping): автозамена прежних версий файла после маппинга (FILE_REPLACE)`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index be4c2bc..eb32646 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -41,7 +41,7 @@ повторы `mapping_attempts` → `ERROR`, `run_mapping_loop` в lifespan (`ExchangeTasks`). Интеграционные тесты: сценарии спеки `osv-result-mapping` на `FakeStorage` с golden JSON (`ОСВ_66`), ключ вне папки, сбой с повтором (monkeypatch), идемпотентность, два листа одного счёта, MISMATCH шапки, нарастающая по Q1, RECOGNIZED. -- [ ] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит +- [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит `FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два разных счёта не заменяют друг друга, `include_superseded`. - [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` -- 2.47.2 From cd4220efc617e6ecc459a8e4aa378594cd00eb5d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:08:25 +0400 Subject: [PATCH 19/35] =?UTF-8?q?test(mapping):=20golden-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=81?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=B2=20=D0=91=D0=94=20(82=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/test_mapping_golden.py` (integration + fixtures, рулинг R-1, tasks 3.4): все `../fixtures/golden/expected_osv_parse/**/*.json` → файл ЮЛ в PROCESSING с заданием `done` → `map_file` в откатываемой транзакции с seed. Инварианты спеки на каждом документе: строк = строк снимка без TOTAL, у строк уровня > 0 есть родитель, шесть сумм равны снимку, `account_id` = план счетов seed для нормализованного кода, вне плана — NULL. 82 снимка, 85 документов, 13510 строк, маппинг ~4 с (бюджет 60 с); 19 кодов строк-счетов вне плана seed. Путь теста в tasks.md 3.4 приведён к рулингу R-1 (`tests/test_mapping_golden.py`). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/test_mapping_golden.py | 161 ++++++++++++++++++ openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 api/tests/test_mapping_golden.py diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py new file mode 100644 index 0000000..de49dfb --- /dev/null +++ b/api/tests/test_mapping_golden.py @@ -0,0 +1,161 @@ +"""Golden-прогон маппинга: все снимки парсера `../fixtures/golden/expected_osv_parse/**` в БД (spec osv-result-mapping +«Все golden-снимки парсера»; рулинг R-1; tasks 3.4). + +Каждый снимок — файл ЮЛ в `PROCESSING` с текущим заданием `done` и `map_file` в одной откатываемой транзакции теста +(`seeded`: план счетов и справочники seed). Цикл маппинга и S3 здесь не нужны: чтение результата и выбор кандидата +проверяет `test_result_mapping.py`, а golden-прогон проверяет сам маппинг на реальных данных. Все снимки маппятся для +одного ЮЛ — файлы с одинаковым счётом и периодом по пути заменяют друг друга (автозамена), документы и строки +заменённых файлов сохраняются, поэтому инварианты проверяются для каждого файла. + +Инварианты: строк `osv_entry` = строк результата без `TOTAL`; у каждой строки уровня > 0 есть родитель (строка того же +документа с `row_number = parent_row_number`); шесть сумм равны снимку; `account_id` = строка плана счетов seed для +нормализованного кода, а для кодов вне плана — NULL (часть кодов golden в плане seed отсутствует — это норма). +""" + +import json +import time +import uuid +from collections import defaultdict +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.contract.result import RecognitionResult +from src.domain.osv_mapping import normalize_account_code +from src.models.accounting import ChartOfAccount +from src.models.enums import FileStatus, RecognitionStatus +from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry +from src.repositories.counterparties import CounterpartyIndex +from src.services.result_mapping import map_file +from tests.factories import make_legal_entity, make_stored_file + +GOLDEN_DIR = Path("golden") / "expected_osv_parse" +EXPECTED_SNAPSHOTS = 82 # результатов ОСВ в fixtures (rb, schastl, synthetic) +TIME_BUDGET_SECONDS = 60.0 # tasks 3.4: маппинг всех снимков — не дольше минуты +SUM_FIELDS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") +TOTAL = "TOTAL" + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + + +def _snapshots(fixtures_dir: Path) -> list[Path]: + return sorted((fixtures_dir / GOLDEN_DIR).rglob("*.json")) + + +def _payload(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult: + data = json.loads(path.read_text("utf-8")) + # у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна) + data["service"] = {"version": "golden", **data["service"]} + return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) + + +def _decimal(value: str | None) -> Decimal | None: + return Decimal(value) if value is not None else None + + +def _check_sheet( # noqa: PLR0912 - все инварианты одного листа в одном месте + name: str, sheet: dict[str, Any], entries: list[OsvEntry], chart: dict[str, uuid.UUID] +) -> tuple[list[str], set[str]]: + """Нарушения инвариантов листа и нормализованные коды строк-счетов, которых нет в плане счетов.""" + problems: list[str] = [] + missing: set[str] = set() + rows = [row for row in sheet["rows"] if row["group_type"] != TOTAL] + if len(entries) != len(rows): + problems.append(f"{name}: строк osv_entry {len(entries)}, в снимке без TOTAL {len(rows)}") + by_row = {entry.row_number: entry for entry in entries} + by_id = {entry.id: entry for entry in entries} + for row in rows: + entry = by_row.get(row["row_number"]) + if entry is None: + problems.append(f"{name}: нет строки {row['row_number']}") + continue + where = f"{name} строка {row['row_number']}" + if entry.level > 0: + parent = by_id.get(entry.parent_entry_id) if entry.parent_entry_id is not None else None + if parent is None or parent.row_number != row["parent_row_number"]: + problems.append(f"{where}: уровень {entry.level} без родителя {row['parent_row_number']}") + for field in SUM_FIELDS: + if getattr(entry, field) != _decimal(row[field]): + problems.append(f"{where}: {field} {getattr(entry, field)} != {row[field]}") + code = row["account_code_raw"] + expected = chart.get(normalize_account_code(code)) if code else None + if entry.account_id != expected: + problems.append(f"{where}: account_id {entry.account_id}, ожидался {expected} для кода {code!r}") + if row["group_type"] == "ACCOUNT" and code and expected is None: + missing.add(normalize_account_code(code)) + return problems, missing + + +async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: Path) -> None: # noqa: PLR0914 + """«Все golden-снимки парсера»: 82 результата маппятся в БД, инварианты спеки на каждом документе, ≤ 60 с.""" + session = seeded + snapshots = _snapshots(fixtures_dir) + assert len(snapshots) == EXPECTED_SNAPSHOTS + owner = await make_legal_entity(session, name="АО «Русский бисквит»", inn="3528052718") + chart = { + code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) + } + index = await CounterpartyIndex.load(session) + + mapped: list[tuple[Path, uuid.UUID, RecognitionResult]] = [] + mapping_seconds = 0.0 + for path in snapshots: + file, job = await make_stored_file( + session, + entity=owner, + status=FileStatus.PROCESSING, + job_status=RecognitionStatus.done, + original_name=path.name, + ) + result = _payload(path, job_id=job.id, file_id=file.id) + started = time.perf_counter() + await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC)) + mapping_seconds += time.perf_counter() - started + mapped.append((path, file.id, result)) + + problems: list[str] = [] + missing_codes: set[str] = set() + documents_total = entries_total = 0 + for path, file_id, _ in mapped: + name = str(path.relative_to(fixtures_dir / GOLDEN_DIR)) + raw = json.loads(path.read_text("utf-8")) + documents = { + document.locator: document + for document in ( + await session.execute(select(Document).where(Document.stored_file_id == file_id)) + ).scalars() + } + sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"] + if set(documents) != {sheet["locator"] for sheet in sheets}: + problems.append(f"{name}: документы {sorted(documents)} не совпадают с листами снимка") + continue + entries_by_document: dict[uuid.UUID, list[OsvEntry]] = defaultdict(list) + rows = await session.execute( + select(OsvEntry).where(OsvEntry.document_id.in_([document.id for document in documents.values()])) + ) + for entry in rows.scalars(): + entries_by_document[entry.document_id].append(entry) + for sheet in sheets: + entries = entries_by_document[documents[sheet["locator"]].id] + sheet_problems, sheet_missing = _check_sheet(f"{name} {sheet['locator']}", sheet, entries, chart) + problems.extend(sheet_problems) + missing_codes |= sheet_missing + documents_total += 1 + entries_total += len(entries) + file_status = (await session.execute(select(StoredFile.status).where(StoredFile.id == file_id))).scalar_one() + if file_status not in {FileStatus.PROCESSED, FileStatus.SUPERSEDED}: + problems.append(f"{name}: статус файла {file_status}") + + print( # noqa: T201 - сводка прогона в отчёт задачи + f"\nGOLDEN: снимков {len(mapped)}, документов {documents_total}, строк {entries_total}, " + f"маппинг {mapping_seconds:.2f} с; кодов строк-счетов вне плана счетов seed: {len(missing_codes)} " + f"{sorted(missing_codes)}" + ) + assert problems == [] + assert mapping_seconds <= TIME_BUDGET_SECONDS diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index eb32646..109cf81 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -44,7 +44,7 @@ - [x] 3.3 **Автозамена.** В `map_file` после `PROCESSED`: поиск прежних файлов по документам, `SUPERSEDED`, `version_no`, аудит `FILE_REPLACE` (системное событие). Тесты: сценарий спеки (CONFIRMED заменяется, `confirmed=true` в description), два разных счёта не заменяют друг друга, `include_superseded`. -- [ ] 3.4 **Golden-прогон.** `tests/fixtures/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` +- [x] 3.4 **Golden-прогон.** `tests/test_mapping_golden.py` (`fixtures`): все `../fixtures/golden/expected_osv_parse/**/*.json` → файл + задание `done` + маппинг через `map_file`; инварианты спеки (строки без TOTAL, родители, суммы, `account_id`). Время: ≤ 60 с на все 82. -- 2.47.2 From dff448d464a831e2b95ff7be5962167374d09d13 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:16:18 +0400 Subject: [PATCH 20/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20Task=208=20(design=20=E2=84=9632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 2810ff6..5e19b55 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -189,3 +189,4 @@ rows без TOTAL, у каждого не-корня есть родитель, | 29 | 2026-10-05 | R-16 (Task 7): статус документа считается по предупреждениям его листа (повтор счёта, сироты, нарастающая по Q1); `metrics.mapping_warnings` — общий список файла | предупреждение одного листа не должно ронять чистый лист другого счёта | передавать весь список | | 30 | 2026-10-05 | R-17 (Task 7): «нарастающая по данным Q1» даёт предупреждение маппинга, поэтому документ `NEEDS_CHECK` (буквально D-7/D-9: `RECOGNIZED` только без предупреждений) | спека; неопределённая нарастающая требует взгляда аналитика | исключить это предупреждение из правила статуса | | 31 | 2026-10-05 | R-18 (Task 7): результат с `job_id`/`file_id` ≠ заданию — неповторяемая ошибка; ошибки S3, кроме «нет объекта», — повторяемые; нелистовые документы (BALANCE/INCOME/OTHER) игнорируются без предупреждения; сироты → предупреждение + `NEEDS_CHECK`; ключ с сегментами `.`/`..`/пустыми отвергается | защита от подмены; ОСВ-only подсрез | убрать проверки | +| 32 | 2026-10-05 | R-19 (Task 8): автозамена берёт `pg_advisory_xact_lock('supersede:{legal_entity_id}')`, затем `FOR UPDATE` старых файлов по `id`, документы перечитываются после блокировки (`confirmed` видит зафиксированное подтверждение); документ без года/квартала в автозамене не участвует | в READ COMMITTED два параллельных маппинга не видят документов друг друга и оба остались бы действующими; NULL-период не доказывает «тот же период» | убрать блокировку / включить NULL-периоды | -- 2.47.2 From f2324b15d37a6d3587ea02463f76a6c6b48f9dd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:18:57 +0400 Subject: [PATCH 21/35] =?UTF-8?q?test(mapping):=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=B8=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=20=E2=80=94=20N=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B9,=20version=5Fno=20=3D=20max=20+=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью T8.1: новый файл с ОСВ 60/62/66 заменяет три прежних файла с version_no 1, 3, 2 по возрастанию id (максимум в середине порядка блокировки) → три FILE_REPLACE, version_no = 4. Мутации `locked[0]` / `locked[-1]` вместо max тест ловит. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/test_result_mapping.py | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 0f76fa8..8905423 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -980,3 +980,43 @@ async def test_one_event_per_replaced_file(committed_sessionmaker: async_session assert "OSV 60 2025 Q1 (CONFIRMED)" in event.description assert "OSV 62 2025 Q1 (NEEDS_CHECK)" in event.description assert "confirmed=true" in event.description + + +async def test_new_file_supersedes_several_files(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Один новый файл (ОСВ 60, 62, 66) заменяет три разных прежних файла: событие `FILE_REPLACE` на каждый заменённый + файл, у нового `version_no` = МАКСИМУМ заменённых + 1. Версии раздаются по возрастанию `id` как 1, 3, 2: максимум — + в середине порядка блокировки, поэтому ни первый, ни последний заблокированный файл его не дают.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id = await _entity(maker) + old_ids = [ + await _old_file(maker, entity_id, [(account, 2025, PeriodQuarter.Q1, DocumentStatus.RECOGNIZED)]) + for account in ("60", "62", "66") + ] + ordered = sorted(old_ids) + versions = dict(zip(ordered, (1, 3, 2), strict=True)) + async with maker() as session, session.begin(): + for file_id, version_no in versions.items(): + file = await session.get(StoredFile, file_id) + assert file is not None + file.version_no = version_no + result = _minimal() + for number, account in enumerate(("62", "66"), start=2): + sheet = copy.deepcopy(_osv(result)) | {"locator": f"sheet:Лист_{number}"} + sheet["header"]["account_code_raw"] = account + result["documents"].append(sheet) + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + new_id, _, _ = await _prepare(maker, storage, result, entity=entity) + + assert await process_once(maker, storage, _settings()) is True + + expected_version = 4 # max(1, 3, 2) + 1 + for old_id in old_ids: + old = await _file(maker, old_id) + assert (old.status, old.superseded_by_file_id) == (FileStatus.SUPERSEDED, new_id) + assert (await _file(maker, new_id)).version_no == expected_version + events = await _replace_events(maker) + assert sorted(event.object_id for event in events) == ordered # type: ignore[type-var] + for event in events: + assert event.user_id is None + assert event.after_value == {"file_id": str(new_id), "version_no": expected_version} -- 2.47.2 From 20983fb3d84bcc649b41087b952656b9edd181a1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:34:35 +0400 Subject: [PATCH 22/35] =?UTF-8?q?feat(files):=20API=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=9E=D0=A1=D0=92;=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /legal-entities/{id}/files (include_superseded, по убыванию uploaded_at), GET /files/{id}, GET /documents/{id} (group_schema листа, warnings — R-6), GET /documents/{id}/entries (limit ≤ 2000, по умолчанию 500), POST /documents/{id}/confirm (DOCUMENT_EDIT; блокировка файла, затем документа; 409 FILE_SUPERSEDED / ALREADY_CONFIRMED / DOCUMENT_NOT_CONFIRMABLE; аудит DOCUMENT_CONFIRM). Видимость — через ЮЛ-владельца, чужое — 404 как несуществующее. Список файлов без N+1: файлы, текущие задания и документы — пакетными запросами. JobBrief.mapping_error — причина ERROR файла по маппингу. Тесты: сценарии documents-api, гонка подтверждения с автозаменой (Review Focus 5), make_entries_from_result (R-2), реальная проверка уникальности документа (T2.2). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/files.py | 13 + api/src/api/endpoints/files/__init__.py | 4 +- api/src/api/endpoints/files/documents.py | 69 +++ api/src/api/endpoints/files/read.py | 55 +++ api/src/repositories/files.py | 129 +++++ api/src/schemas/documents.py | 88 ++++ api/src/schemas/files.py | 5 +- api/src/services/documents.py | 184 +++++++ api/src/services/primary_files.py | 38 +- api/tests/factories.py | 71 ++- api/tests/test_api_documents.py | 448 ++++++++++++++++++ api/tests/test_api_files_read.py | 281 +++++++++++ api/tests/test_factories.py | 44 ++ api/tests/test_openapi_routes.py | 27 +- .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 16 files changed, 1449 insertions(+), 15 deletions(-) create mode 100644 api/src/api/endpoints/files/documents.py create mode 100644 api/src/api/endpoints/files/read.py create mode 100644 api/src/repositories/files.py create mode 100644 api/src/schemas/documents.py create mode 100644 api/src/services/documents.py create mode 100644 api/tests/test_api_documents.py create mode 100644 api/tests/test_api_files_read.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index 6b99064..4cde09f 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -8,6 +8,7 @@ from src.api.dependencies.audit import RequestAudit, get_request_audit from src.api.dependencies.db import SessionDep from src.api.dependencies.directories import SettingsDep, get_scope from src.domain.policy import VisibilityScope +from src.services.documents import DocumentService from src.services.primary_files import PrimaryFilesService @@ -23,3 +24,15 @@ def get_primary_files_service( PrimaryFilesServiceDep = Annotated[PrimaryFilesService, Depends(get_primary_files_service)] + + +def get_document_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> DocumentService: + """Сервис документов: карточка, строки ОСВ, подтверждение (видимость — через ЮЛ-владельца).""" + return DocumentService(session, audit, scope) + + +DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)] diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index 9b2223b..b1b0f9e 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,7 +2,9 @@ from fastapi import APIRouter -from src.api.endpoints.files import upload +from src.api.endpoints.files import documents, read, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) +router.include_router(read.router) +router.include_router(documents.router) diff --git a/api/src/api/endpoints/files/documents.py b/api/src/api/endpoints/files/documents.py new file mode 100644 index 0000000..244d214 --- /dev/null +++ b/api/src/api/endpoints/files/documents.py @@ -0,0 +1,69 @@ +"""`/documents/{document_id}` — карточка документа, строки ОСВ и подтверждение (spec documents-api «Документ и строки», +«Подтверждение документа»; audit-trail `DOCUMENT_CONFIRM`; design D-11). + +Чтение — `DOCUMENT_VIEW`, подтверждение — `DOCUMENT_EDIT`; право проверяется до видимости, документ ЮЛ вне +видимости — тот же 404, что несуществующий. +""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import DocumentServiceDep +from src.schemas.common import ErrorDetail, Page +from src.schemas.documents import DocumentOut, EntriesPageParams, EntryOut + +router = APIRouter(prefix="/documents") + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{document_id}", response_model=DocumentOut, dependencies=VIEW, responses=NOT_FOUND, summary="Карточка документа" +) +async def get_document(document_id: uuid.UUID, service: DocumentServiceDep) -> DocumentOut: + """Документ: статус, проверки, `locator`, схема группировок листа, предупреждения маппинга файла, `file_id`.""" + return await service.get(document_id) + + +@router.get( + "/{document_id}/entries", + response_model=Page[EntryOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Строки ОСВ документа", +) +async def list_entries( + document_id: uuid.UUID, + service: DocumentServiceDep, + page: Annotated[EntriesPageParams, Query()], +) -> Page[EntryOut]: + """Строки по возрастанию `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id`.""" + items, total = await service.entries(document_id, page) + return Page[EntryOut]( + items=[EntryOut.model_validate(entry) for entry in items], total=total, limit=page.limit, offset=page.offset + ) + + +@router.post( + "/{document_id}/confirm", + response_model=DocumentOut, + dependencies=EDIT, + responses={ + **NOT_FOUND, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`", + }, + }, + summary="Подтвердить документ", +) +async def confirm_document(document_id: uuid.UUID, service: DocumentServiceDep) -> DocumentOut: + """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`; аудит `DOCUMENT_CONFIRM`.""" + return await service.confirm(document_id) diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py new file mode 100644 index 0000000..0ffe6c4 --- /dev/null +++ b/api/src/api/endpoints/files/read.py @@ -0,0 +1,55 @@ +"""`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла +(spec documents-api «Файлы организации и карточка файла»; design D-11). + +Право `DOCUMENT_VIEW` проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий. +Список — по убыванию `uploaded_at`, заменённые (`SUPERSEDED`) — только при `include_superseded=true`. +""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.files import PrimaryFilesServiceDep +from src.schemas.common import ErrorDetail, Page, PageParams +from src.schemas.files import FileOut + +router = APIRouter() + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +class FileListQuery(PageParams): + """Query списка файлов ЮЛ: пагинация и `include_superseded` (по умолчанию заменённые скрыты).""" + + include_superseded: bool = False + + +@router.get( + "/legal-entities/{legal_entity_id}/files", + response_model=Page[FileOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Файлы организации", +) +async def list_files( + legal_entity_id: uuid.UUID, + service: PrimaryFilesServiceDep, + query: Annotated[FileListQuery, Query()], +) -> Page[FileOut]: + """Файлы ЮЛ (новые первыми): статус, текущее задание (ошибка, предупреждения), документы файла.""" + page = PageParams(limit=query.limit, offset=query.offset) + items, total = await service.page_files(legal_entity_id, include_superseded=query.include_superseded, page=page) + return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset) + + +@router.get( + "/files/{file_id}", response_model=FileOut, dependencies=VIEW, responses=NOT_FOUND, summary="Карточка файла" +) +async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileOut: + """Карточка файла — как элемент списка файлов организации.""" + return await service.get_file(file_id) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py new file mode 100644 index 0000000..97c7ddb --- /dev/null +++ b/api/src/repositories/files.py @@ -0,0 +1,129 @@ +"""Репозиторий первичных файлов, документов и строк ОСВ (spec documents-api; design D-11). + +SQL без бизнес-правил; видимость — через ЮЛ-владельца (`src.domain.policy.legal_entities_visible`): файл или документ +вне видимости неотличим от несуществующего (`None`). Связей `relationship()` в моделях нет, поэтому задания и документы +страницы файлов читаются пакетно (`current_jobs`, `documents_by_file`) — число запросов не зависит от числа файлов. + +Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. +""" + +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import Select, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.policy import VisibilityScope, legal_entities_visible +from src.models.directories import LegalEntity +from src.models.enums import FileStatus +from src.models.files import Document, RecognitionJob, StoredFile +from src.models.osv import OsvEntry + + +def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: + """Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`).""" + return legal_entities_visible(stmt.join(LegalEntity, LegalEntity.id == StoredFile.legal_entity_id), scope) + + +def _visible_documents(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: + """Документы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `Document`).""" + return legal_entities_visible(stmt.join(LegalEntity, LegalEntity.id == Document.legal_entity_id), scope) + + +async def list_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + entity_id: uuid.UUID, + *, + include_superseded: bool, + limit: int, + offset: int, +) -> tuple[list[StoredFile], int]: + """Страница видимых файлов ЮЛ по убыванию `uploaded_at` (затем `id`) и общее число по фильтру; `SUPERSEDED` — только + при `include_superseded`.""" + + def filtered(stmt: Select[Any]) -> Select[Any]: + stmt = _visible_files(stmt, scope).where(StoredFile.legal_entity_id == entity_id) + if not include_superseded: + stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED) + return stmt + + total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one() + page_stmt = ( + filtered(select(StoredFile)) + .order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id) + .limit(limit) + .offset(offset) + ) + files = list((await session.execute(page_stmt)).scalars().all()) + return files, total + + +async def get_file(session: AsyncSession, scope: VisibilityScope, file_id: uuid.UUID) -> StoredFile | None: + """Файл по id; `None` и для несуществующего, и для файла ЮЛ вне видимости.""" + stmt = _visible_files(select(StoredFile).where(StoredFile.id == file_id), scope) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def current_jobs(session: AsyncSession, files: Sequence[StoredFile]) -> dict[uuid.UUID, RecognitionJob]: + """Текущие задания файлов (`stored_file.current_recognition_job_id`) одним запросом: `file_id → задание`.""" + job_ids = [file.current_recognition_job_id for file in files if file.current_recognition_job_id is not None] + if not job_ids: + return {} + jobs = (await session.execute(select(RecognitionJob).where(RecognitionJob.id.in_(job_ids)))).scalars().all() + return {job.file_id: job for job in jobs} + + +async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, list[Document]]: + """Документы файлов одним запросом: `file_id → документы` (по типу, счёту, `locator`).""" + if not file_ids: + return {} + stmt = ( + select(Document) + .where(Document.stored_file_id.in_(file_ids)) + .order_by(Document.document_type, Document.osv_account_code, Document.locator, Document.id) + ) + grouped: dict[uuid.UUID, list[Document]] = {file_id: [] for file_id in file_ids} + for document in (await session.execute(stmt)).scalars(): + grouped[document.stored_file_id].append(document) + return grouped + + +async def get_document(session: AsyncSession, scope: VisibilityScope, document_id: uuid.UUID) -> Document | None: + """Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости.""" + stmt = _visible_documents(select(Document).where(Document.id == document_id), scope) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def list_entries( + session: AsyncSession, document_id: uuid.UUID, *, limit: int, offset: int +) -> tuple[list[OsvEntry], int]: + """Страница строк документа по возрастанию `row_number` и их общее число (видимость документа — у вызывающего).""" + total = ( + await session.execute(select(func.count(OsvEntry.id)).where(OsvEntry.document_id == document_id)) + ).scalar_one() + stmt = ( + select(OsvEntry) + .where(OsvEntry.document_id == document_id) + .order_by(OsvEntry.row_number, OsvEntry.id) + .limit(limit) + .offset(offset) + ) + return list((await session.execute(stmt)).scalars().all()), total + + +async def lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | None: + """`SELECT stored_file … FOR UPDATE` с перечитыванием (объект в identity map мог устареть).""" + stmt = ( + select(StoredFile).where(StoredFile.id == file_id).with_for_update().execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Document | None: + """`SELECT document … FOR UPDATE` с перечитыванием; вызывать после `lock_file` его файла (порядок блокировок).""" + stmt = ( + select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py new file mode 100644 index 0000000..b66721f --- /dev/null +++ b/api/src/schemas/documents.py @@ -0,0 +1,88 @@ +"""Схемы API документов и строк ОСВ (spec documents-api «Документ и строки», «Подтверждение документа»; design D-11). + +`DocumentOut` — карточка документа: поля `document`, `file_id`, схема группировок листа из +`recognition_job.group_schema` (элемент с `locator` документа) и предупреждения маппинга файла целиком (R-6). +`EntryOut` — строка `osv_entry`; суммы — `Decimal` (в JSON — строка, без `float`). `EntriesPageParams` — пагинация +строк: `limit` ≤ 2000, по умолчанию 500. +""" + +import datetime +import uuid +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from src.models.enums import ( + DocumentCheckResult, + DocumentStatus, + DocumentType, + OsvDataType, + OsvGroupType, + PeriodQuarter, +) + +DEFAULT_ENTRIES_LIMIT = 500 +MAX_ENTRIES_LIMIT = 2000 + + +class GroupOut(BaseModel): + """Группировка из шапки листа ОСВ (как в результате распознавания): тип, исходный текст, есть ли ИНН.""" + + type: str + name: str | None = None + has_inn: bool | None = None + + +class DocumentOut(BaseModel): + """Карточка документа: статус, проверки, `locator`, схема группировок листа и предупреждения маппинга файла.""" + + id: uuid.UUID + file_id: uuid.UUID + legal_entity_id: uuid.UUID + document_type: DocumentType + osv_account_code: str | None + period_year: int | None + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + locator: str | None + status: DocumentStatus + period_check: DocumentCheckResult | None + counterparty_check: DocumentCheckResult | None + confirmed_by: uuid.UUID | None + confirmed_at: datetime.datetime | None + group_schema: list[GroupOut] | None + warnings: list[str] + + +class EntryOut(BaseModel): + """Строка ОСВ (`osv_entry`); дерево восстанавливается по `parent_entry_id`.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + row_number: int | None + level: int | None + parent_entry_id: uuid.UUID | None + row_value: str | None + group_type: OsvGroupType | None + group_name: str | None + data_type: OsvDataType | None + account_code: str | None + account_root: str | None + account_id: uuid.UUID | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + + +class EntriesPageParams(BaseModel): + """Пагинация строк документа из query: `Annotated[EntriesPageParams, Query()]`.""" + + limit: int = Field(DEFAULT_ENTRIES_LIMIT, ge=1, le=MAX_ENTRIES_LIMIT) + offset: int = Field(0, ge=0) diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index cb7f59b..1eaab38 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -47,12 +47,15 @@ class UploadResult(BaseModel): class JobBrief(BaseModel): - """Текущее задание распознавания файла: статус, причина ошибки, предупреждения сервиса и маппинга.""" + """Текущее задание распознавания файла: статус, причина ошибки сервиса (`error_code`/`error_message`) и маппинга + (`mapping_error` — `metrics.mapping_error`: файл в `ERROR` после маппинга при задании `done`), предупреждения + сервиса (`metrics.warnings`) и маппинга (`metrics.mapping_warnings`).""" id: uuid.UUID status: RecognitionStatus error_code: str | None error_message: str | None + mapping_error: str | None warnings: list[str] mapping_warnings: list[str] finished_at: datetime.datetime | None diff --git a/api/src/services/documents.py b/api/src/services/documents.py new file mode 100644 index 0000000..672dc16 --- /dev/null +++ b/api/src/services/documents.py @@ -0,0 +1,184 @@ +"""Документы первичных файлов: карточка, строки ОСВ, подтверждение (spec documents-api «Документ и строки», +«Подтверждение документа»; audit-trail `DOCUMENT_CONFIRM`; design D-11, R-6) и сборка карточки файла (`file_out`). + +Видимость — через ЮЛ-владельца (`repositories.files`); документ вне видимости — тот же 404, что несуществующий. + +Подтверждение и автозамена (`services.result_mapping.supersede_previous`) могут идти одновременно. Порядок блокировок +подтверждения — сначала `stored_file … FOR UPDATE`, затем `document … FOR UPDATE`: автозамена блокирует файлы (новый, +затем старые по id) и документы не блокирует, поэтому взаимной блокировки нет. Файл и документ перечитываются под +блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). +""" + +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.policy import VisibilityScope +from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus +from src.models.files import Document, RecognitionJob, StoredFile +from src.models.osv import OsvEntry +from src.repositories import files as files_repo +from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut +from src.schemas.files import DocumentBrief, FileOut, JobBrief +from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY + +DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего +OBJECT_TYPE = "document" +WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предупреждения сервиса распознавания +FILE_SUPERSEDED = "FILE_SUPERSEDED" +ALREADY_CONFIRMED = "ALREADY_CONFIRMED" +DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" +CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) + + +def _metrics(job: RecognitionJob | None) -> dict[str, Any]: + return job.metrics if job is not None and isinstance(job.metrics, dict) else {} + + +def _strings(value: Any) -> list[str]: + """Список строк из JSONB (`metrics.*`); иное содержимое — пустой список.""" + return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] + + +def job_brief(job: RecognitionJob) -> JobBrief: + """Текущее задание файла для карточки: статус, ошибки сервиса и маппинга, предупреждения.""" + metrics = _metrics(job) + mapping_error = metrics.get(MAPPING_ERROR_KEY) + return JobBrief( + id=job.id, + status=job.status, + error_code=job.error_code, + error_message=job.error_message, + mapping_error=mapping_error if isinstance(mapping_error, str) else None, + warnings=_strings(metrics.get(WARNINGS_KEY)), + mapping_warnings=_strings(metrics.get(MAPPING_WARNINGS_KEY)), + finished_at=job.finished_at, + ) + + +def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[Document]) -> FileOut: + """Карточка файла (spec documents-api «Файлы организации и карточка файла»).""" + return FileOut( + id=file.id, + legal_entity_id=file.legal_entity_id, + original_name=file.original_name, + file_type=file.file_type, + status=file.status, + version_no=file.version_no, + size_bytes=file.size_bytes, + content_hash=file.content_hash, + uploaded_at=file.uploaded_at, + uploaded_by=file.uploaded_by, + superseded_by_file_id=file.superseded_by_file_id, + job=job_brief(job) if job is not None else None, + documents=[DocumentBrief.model_validate(document) for document in documents], + ) + + +def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[GroupOut] | None: + """Группировки листа документа: элемент `job.group_schema` с тем же `locator` (R-6); нет — `None`.""" + if job is None or locator is None or not isinstance(job.group_schema, list): + return None + for sheet in job.group_schema: + if isinstance(sheet, dict) and sheet.get("locator") == locator: + groups = sheet.get("groups") + return [GroupOut.model_validate(group) for group in groups] if isinstance(groups, list) else [] + return None + + +def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut: + """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга).""" + return DocumentOut( + id=document.id, + file_id=document.stored_file_id, + legal_entity_id=document.legal_entity_id, + document_type=document.document_type, + osv_account_code=document.osv_account_code, + period_year=document.period_year, + period_quarter=document.period_quarter, + is_cumulative=document.is_cumulative, + locator=document.locator, + status=document.status, + period_check=document.period_check, + counterparty_check=document.counterparty_check, + confirmed_by=document.confirmed_by, + confirmed_at=document.confirmed_at, + group_schema=_group_schema(job, document.locator), + # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла + warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), + ) + + +class DocumentService: + """Чтение и подтверждение документов от имени пользователя запроса (права — на маршруте, видимость — здесь).""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def _visible(self, document_id: uuid.UUID) -> Document: + document = await files_repo.get_document(self._session, self._scope, document_id) + if document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + return document + + async def _current_job(self, file: StoredFile | None) -> RecognitionJob | None: + if file is None or file.current_recognition_job_id is None: + return None + return await self._session.get(RecognitionJob, file.current_recognition_job_id) + + async def get(self, document_id: uuid.UUID) -> DocumentOut: + """Карточка видимого документа; иначе 404.""" + document = await self._visible(document_id) + file = await self._session.get(StoredFile, document.stored_file_id) + return document_out(document, await self._current_job(file)) + + async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]: + """Страница строк видимого документа по `row_number`; иначе 404.""" + document = await self._visible(document_id) + return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) + + async def confirm(self, document_id: uuid.UUID) -> DocumentOut: + """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. + + 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — + `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. + """ + visible = await self._visible(document_id) + file = await files_repo.lock_file(self._session, visible.stored_file_id) + document = await files_repo.lock_document(self._session, document_id) + if file is None or document is None: # удалены параллельно, пока ждали блокировку + raise NotFoundError(DOCUMENT_NOT_FOUND) + if file.status is FileStatus.SUPERSEDED: + raise ConflictError( + FILE_SUPERSEDED, "файл документа заменён новой версией: подтвердите документ нового файла" + ) + if document.status is DocumentStatus.CONFIRMED: + raise ConflictError(ALREADY_CONFIRMED, "документ уже подтверждён") + if document.status not in CONFIRMABLE_STATUSES: + raise ConflictError( + DOCUMENT_NOT_CONFIRMABLE, + f"документ в статусе {document.status.value} подтвердить нельзя: нужен RECOGNIZED или NEEDS_CHECK", + ) + before = document.status + now = utc_now() + document.status = DocumentStatus.CONFIRMED + document.confirmed_by = self._audit.user_id + document.confirmed_at = now + document.updated_at = now + await self._session.flush() + await self._audit.record( + action=AuditAction.DOCUMENT_CONFIRM, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=document.id, + before_value={"status": before.value}, + after_value={"status": DocumentStatus.CONFIRMED.value}, + ) + return document_out(document, await self._current_job(file)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 9a7f07d..fce173f 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -1,5 +1,6 @@ """Первичные файлы организации: загрузка (spec primary-files «Загрузка файлов организации», «Дедуп по содержимому»; -audit-trail «События файлов и документов»; design D-4). +audit-trail «События файлов и документов»; design D-4) и чтение — список файлов ЮЛ и карточка файла (spec documents-api +«Файлы организации и карточка файла»; design D-11): файл, текущее задание и документы, пакетно без N+1. Загрузка одного файла — SAVEPOINT (`begin_nested()`) внутри транзакции запроса (`SessionDep`): отказ файла откатывает только его записи, принятые раньше файлы того же запроса остаются. Порядок внутри SAVEPOINT: @@ -37,6 +38,10 @@ from src.models.directories import LegalEntity from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole from src.models.files import RecognitionJob, S3Object, StoredFile from src.repositories import directories as directories_repo +from src.repositories import files as files_repo +from src.schemas.common import PageParams +from src.schemas.files import FileOut +from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND OBJECT_TYPE = "stored_file" @@ -45,6 +50,7 @@ DUPLICATE_FILE = "DUPLICATE_FILE" FILE_TOO_LARGE = "FILE_TOO_LARGE" STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" +FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего class StorageUnavailableError(DomainError): @@ -124,6 +130,36 @@ class PrimaryFilesService: raise StorageUnavailableError() return self._storage + async def page_files( + self, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams + ) -> tuple[list[FileOut], int]: + """Карточки файлов видимого ЮЛ по убыванию `uploaded_at` и их общее число; ЮЛ вне видимости — 404. + + Три запроса на страницу независимо от числа файлов: файлы (+ `count`), текущие задания, документы.""" + entity = await self.get_entity(entity_id) + files, total = await files_repo.list_files( + self._session, + self._scope, + entity.id, + include_superseded=include_superseded, + limit=page.limit, + offset=page.offset, + ) + return await self._cards(files), total + + async def get_file(self, file_id: uuid.UUID) -> FileOut: + """Карточка видимого файла; чужой и несуществующий — одинаковый 404.""" + file = await files_repo.get_file(self._session, self._scope, file_id) + if file is None: + raise NotFoundError(FILE_NOT_FOUND) + (card,) = await self._cards([file]) + return card + + async def _cards(self, files: list[StoredFile]) -> list[FileOut]: + jobs = await files_repo.current_jobs(self._session, files) + documents = await files_repo.documents_by_file(self._session, [file.id for file in files]) + return [file_out(file, jobs.get(file.id), documents.get(file.id, [])) for file in files] + async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None: """Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому».""" stmt = ( diff --git a/api/tests/factories.py b/api/tests/factories.py index 5fffbcf..f8b5007 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -5,8 +5,8 @@ умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте. - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. - `make_stored_file`, `make_document`, `make_result_object` — цепочка файла/задания/документа и объект результата в - `FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` создаёт - Task 9 (R-2). + `FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` — + документы и строки ОСВ файла из JSON результата (golden-снимок) теми же функциями, что и цикл маппинга (R-2). - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. `make_file_job` / `add_job` — файл с текущим заданием и второе задание того же файла (статусы `stored_file` в обмене, Срез 2c). Всё созданное удаляется @@ -21,10 +21,13 @@ import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.contract.result import OsvDocument, RecognitionResult +from src.domain.osv_mapping import build_entries, document_fields from src.models.access import AppUser, Role, UserRole +from src.models.accounting import ChartOfAccount from src.models.directories import ( CompanyGroup, LegalEntity, @@ -33,6 +36,7 @@ from src.models.directories import ( RelatedGroupMember, ) from src.models.enums import ( + DocumentCheckResult, DocumentStatus, DocumentType, EntityType, @@ -46,6 +50,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.services.result_mapping import MAPPING_WARNINGS_KEY, _entry_values, _group_schema from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} @@ -278,6 +283,66 @@ async def make_result_object( return s3 +async def make_entries_from_result( + session: AsyncSession, + file: StoredFile, + job: RecognitionJob, + result_json: dict[str, Any], + *, + status: DocumentStatus = DocumentStatus.NEEDS_CHECK, +) -> list[Document]: + """Документ на каждый лист ОСВ результата + его строки `osv_entry` (`flush`), `job.group_schema` и + `metrics.mapping_warnings = []` — как после цикла маппинга, но без S3, резолва контрагентов и автозамены. + + Строки строит `build_entries` (без `TOTAL`, `parent_entry_id` по `parent_row_number`, `account_id` по плану счетов + сессии), поля строки — `_entry_values` маппера. У golden-снимка нет `job_id`/`file_id`/`service.version` — + подставляются. Статус документов — `status` (по умолчанию `NEEDS_CHECK`), проверка периода — `UNKNOWN`. + """ + assert job.file_id == file.id # noqa: S101 - защита от перепутанных аргументов в тестах + payload = {**result_json, "job_id": str(job.id), "file_id": str(file.id)} + payload["service"] = {"version": "test", **result_json.get("service", {"name": "tabular-parser"})} + result = RecognitionResult.model_validate(payload) + sheets = [doc for doc in result.documents if isinstance(doc, OsvDocument)] + chart = { + code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) + } + now = _now() + documents: list[Document] = [] + entries: list[dict[str, Any]] = [] + for sheet in sheets: + fields = document_fields(sheet) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.OSV, + osv_account_code=fields.osv_account_code, + period_year=fields.period_year, + period_quarter=fields.period_quarter, + is_cumulative=fields.is_cumulative, + locator=fields.locator, + status=status, + period_check=DocumentCheckResult.UNKNOWN, + counterparty_check=DocumentCheckResult.UNKNOWN, + created_at=now, + updated_at=now, + ) + documents.append(document) + entries.extend( + _entry_values(entry, document_id=document.id, job_id=job.id, owner_id=file.legal_entity_id) + for entry in build_entries(sheet, chart).entries + ) + session.add_all(documents) + await session.flush() + if entries: + await session.execute(insert(OsvEntry).values(entries)) + job.group_schema = [_group_schema(sheet) for sheet in sheets] + metrics = dict(job.metrics) if isinstance(job.metrics, dict) else {} + job.metrics = {**metrics, MAPPING_WARNINGS_KEY: metrics.get(MAPPING_WARNINGS_KEY, [])} + await session.flush() + return documents + + async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py new file mode 100644 index 0000000..74d7869 --- /dev/null +++ b/api/tests/test_api_documents.py @@ -0,0 +1,448 @@ +"""Документ, строки ОСВ и подтверждение (spec documents-api «Документ и строки», «Подтверждение документа»; +audit-trail `DOCUMENT_CONFIRM`; design D-11; Review Focus 5 плана). + +Основные сценарии — поверх фикстуры `app` (транзакция теста). Гонка «подтвердить» vs «автозамена» — на `committed_*` +(временная БД, сессия запроса как в проде): транзакция автозамены держит файл под `FOR UPDATE`, подтверждение ждёт её +и после commit видит `SUPERSEDED`. +""" + +import asyncio +import datetime +import json +import uuid +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentStatus, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document, RecognitionJob, StoredFile +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_document, + make_entries_from_result, + make_legal_entity, + make_stored_file, +) + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет +ROSTOV = "Ростовский региональный филиал" + +GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" +GOLDEN_OSV_66_ENTRIES = 9 # 10 строк снимка минус TOTAL (R-13) +DEFAULT_ENTRIES_LIMIT = 500 +MAX_ENTRIES_LIMIT = 2000 +LOCK_WAIT_SECONDS = 10.0 + +pytestmark = pytest.mark.integration + + +async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + return await make_legal_entity(session, name=name, group=group) + + +async def _document( + session: AsyncSession, + *, + status: DocumentStatus = DocumentStatus.NEEDS_CHECK, + file_status: FileStatus = FileStatus.PROCESSED, +) -> tuple[StoredFile, RecognitionJob, Document]: + entity = await _rostov_entity(session) + file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done) + document = await make_document(session, file=file, job=job, status=status) + return file, job, document + + +async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() + + +async def _confirm_events(session: AsyncSession, document_id: uuid.UUID) -> list[AuditEvent]: + stmt = select(AuditEvent).where( + AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document_id + ) + return list((await session.execute(stmt)).scalars().all()) + + +def _golden(fixtures_dir: Path) -> dict[str, Any]: + data: dict[str, Any] = json.loads((fixtures_dir / GOLDEN_OSV_66).read_text("utf-8")) + return data + + +# --- карточка документа ---------------------------------------------------------------------------------------------- + + +async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + file, job, document = await _document(seeded) + document.locator = "sheet:Лист_2" + job.group_schema = [ + {"locator": "sheet:Лист_1", "groups": [{"type": "ACCOUNT", "name": "Счет", "has_inn": None}]}, + { + "locator": "sheet:Лист_2", + "groups": [ + {"type": "ACCOUNT", "name": "Счет", "has_inn": None}, + {"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}, + ], + }, + ] + job.metrics = {"warnings": ["парсер"], "mapping_warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"]} + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(f"/documents/{document.id}") + + assert response.status_code == HTTP_OK, response.text + assert response.json() == { + "id": str(document.id), + "file_id": str(file.id), + "legal_entity_id": str(file.legal_entity_id), + "document_type": "OSV", + "osv_account_code": "60", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": True, + "locator": "sheet:Лист_2", + "status": "NEEDS_CHECK", + "period_check": None, + "counterparty_check": None, + "confirmed_by": None, + "confirmed_at": None, + "group_schema": [ + {"type": "ACCOUNT", "name": "Счет", "has_inn": None}, + {"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}, + ], + # R-6: предупреждения маппинга файла целиком, без фильтра по листу + "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], + } + + +async def test_document_card_without_group_schema(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded) + client = await auth_client(KA_RF) + + body = (await client.get(f"/documents/{document.id}")).json() + + assert body["group_schema"] is None + assert body["warnings"] == [] + + +# --- строки ---------------------------------------------------------------------------------------------------------- + + +@pytest.mark.fixtures +async def test_entries_as_tree_on_golden_osv_66( + seeded: AsyncSession, auth_client: AuthClientFactory, fixtures_dir: Path +) -> None: + """«Строки деревом»: первая строка — счёт 66 уровня 0 без родителя, у строк уровня 2 родитель — субсчёт.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + (document,) = await make_entries_from_result(seeded, file, job, _golden(fixtures_dir)) + client = await auth_client(KA_RF) + + response = await client.get(f"/documents/{document.id}/entries") + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["total"], body["limit"], body["offset"]) == (GOLDEN_OSV_66_ENTRIES, DEFAULT_ENTRIES_LIMIT, 0) + rows = body["items"] + assert [row["row_number"] for row in rows] == sorted(row["row_number"] for row in rows) + assert all(row["group_type"] != "TOTAL" for row in rows) + first = rows[0] + assert (first["row_value"], first["account_code"], first["level"], first["parent_entry_id"]) == ( + "66", + "66", + 0, + None, + ) + assert first["closing_credit"] == "54.64" # деньги — строкой, без float + assert first["account_root"] == "66" + by_id = {row["id"]: row for row in rows} + level_2 = [row for row in rows if row["level"] == 2] # noqa: PLR2004 + assert level_2 + for row in level_2: + parent = by_id[row["parent_entry_id"]] + assert (parent["level"], parent["group_type"]) == (1, "ACCOUNT") + assert parent["account_code"] == row["account_code"] + assert row["group_type"] == "COUNTERPARTY" + yug = next(row for row in rows if row["row_number"] == 18) # noqa: PLR2004 + assert yug["account_id"] is not None + assert yug["counterparty_name"] is None or isinstance(yug["counterparty_name"], str) + assert set(first) == { + "id", + "row_number", + "level", + "parent_entry_id", + "row_value", + "group_type", + "group_name", + "data_type", + "account_code", + "account_root", + "account_id", + "counterparty_name", + "counterparty_inn", + "counterparty_id", + "opening_debit", + "opening_credit", + "turnover_debit", + "turnover_credit", + "closing_debit", + "closing_credit", + } + + +async def test_entries_pagination_and_limits(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 + ) + result = json.loads( + (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json").read_text("utf-8") + ) + (document,) = await make_entries_from_result(seeded, file, job, result) + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/entries" + + page = (await client.get(url, params={"limit": 1, "offset": 1})).json() + widest = await client.get(url, params={"limit": MAX_ENTRIES_LIMIT}) + too_wide = await client.get(url, params={"limit": MAX_ENTRIES_LIMIT + 1}) + + assert (page["total"], page["limit"], page["offset"]) == (2, 1, 1) + assert [row["row_number"] for row in page["items"]] == [2] + assert widest.status_code == HTTP_OK + assert too_wide.status_code == HTTP_UNPROCESSABLE + + +# --- подтверждение --------------------------------------------------------------------------------------------------- + + +async def test_confirm_needs_check_document(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Подтверждение»: `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала.""" + _, _, document = await _document(seeded) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert body["status"] == "CONFIRMED" + assert body["confirmed_by"] == str(user_id) + assert body["confirmed_at"] is not None + await seeded.refresh(document) + assert document.status == DocumentStatus.CONFIRMED + assert document.confirmed_by == user_id + assert document.confirmed_at is not None + (event,) = await _confirm_events(seeded, document.id) + assert event.section == AuditSection.PRIMARY_DOCUMENTS + assert event.object_type == "document" + assert event.user_id == user_id + assert event.before_value == {"status": "NEEDS_CHECK"} + assert event.after_value == {"status": "CONFIRMED"} + + +async def test_confirm_recognized_document(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded, status=DocumentStatus.RECOGNIZED) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + (event,) = await _confirm_events(seeded, document.id) + assert event.before_value == {"status": "RECOGNIZED"} + + +async def test_repeated_confirm_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded) + client = await auth_client(KA_RF) + + first = await client.post(f"/documents/{document.id}/confirm") + second = await client.post(f"/documents/{document.id}/confirm") + + assert first.status_code == HTTP_OK + assert second.status_code == HTTP_CONFLICT + assert second.json()["detail"]["code"] == "ALREADY_CONFIRMED" + assert len(await _confirm_events(seeded, document.id)) == 1 + + +async def test_error_document_is_not_confirmable(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded, status=DocumentStatus.ERROR) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "DOCUMENT_NOT_CONFIRMABLE" + assert await _confirm_events(seeded, document.id) == [] + + +async def test_document_of_superseded_file_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded, file_status=FileStatus.SUPERSEDED) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" + await seeded.refresh(document) + assert document.status == DocumentStatus.NEEDS_CHECK + assert await _confirm_events(seeded, document.id) == [] + + +async def test_confirm_rereads_file_status(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Файл заменён между чтением и подтверждением: объект файла в сессии устарел (`PROCESSED`), а в БД — + `SUPERSEDED`; подтверждение перечитывает файл под блокировкой и отвечает 409.""" + file, _, document = await _document(seeded) + await seeded.execute( + text("UPDATE stored_file SET status = 'SUPERSEDED' WHERE id = :id"), {"id": file.id} + ) # мимо identity map + assert file.status == FileStatus.PROCESSED + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" + + +async def _committed_document(maker: async_sessionmaker[AsyncSession]) -> tuple[uuid.UUID, uuid.UUID]: + async with maker() as session, session.begin(): + entity = await _rostov_entity(session, "ООО Гонка") + 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, status=DocumentStatus.NEEDS_CHECK) + return file.id, document.id + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + """Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки строки (запрос подтверждения).""" + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("подтверждение не встало в ожидание блокировки файла") + + +async def test_confirm_races_with_supersede( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Review Focus 5: автозамена держит файл под `FOR UPDATE` и переводит его в `SUPERSEDED`; подтверждение, + начатое в это время, ждёт блокировку и после commit автозамены отвечает 409 `FILE_SUPERSEDED`.""" + file_id, document_id = await _committed_document(committed_sessionmaker) + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as replacer, replacer.begin(): + await replacer.execute(select(StoredFile.id).where(StoredFile.id == file_id).with_for_update()) + await replacer.execute( + update(StoredFile) + .where(StoredFile.id == file_id) + .values(status=FileStatus.SUPERSEDED, updated_at=datetime.datetime.now(datetime.UTC)) + ) + confirm = asyncio.create_task(client.post(f"/documents/{document_id}/confirm")) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not confirm.done() + response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" + async with committed_sessionmaker() as session: + document = await session.get(Document, document_id) + assert document is not None + assert document.status == DocumentStatus.NEEDS_CHECK + events = ( + await session.execute( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) + ) + ).scalar_one() + assert events == 0 + + +# --- видимость и права ----------------------------------------------------------------------------------------------- + + +async def test_confirm_requires_document_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded) + client = await auth_client(RKA) # свой филиал, только DOCUMENT_VIEW + + assert (await client.get(f"/documents/{document.id}")).status_code == HTTP_OK + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_FORBIDDEN + await seeded.refresh(document) + assert document.status == DocumentStatus.NEEDS_CHECK + + +async def test_reading_documents_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + _, _, document = await _document(seeded) + client = await auth_client(CURATOR_GO) + + assert (await client.get(f"/documents/{document.id}")).status_code == HTTP_FORBIDDEN + assert (await client.get(f"/documents/{document.id}/entries")).status_code == HTTP_FORBIDDEN + + +@pytest.mark.parametrize( + ("method", "suffix"), + [("get", ""), ("get", "/entries"), ("post", "/confirm")], +) +async def test_foreign_and_missing_document_are_same_404( + seeded: AsyncSession, auth_client: AuthClientFactory, method: str, suffix: str +) -> None: + _, _, document = await _document(seeded) + client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал: ЮЛ Ростова не видит + + foreign = await client.request(method, f"/documents/{document.id}{suffix}") + missing = await client.request(method, f"/documents/{uuid.uuid4()}{suffix}") + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + await seeded.refresh(document) + assert document.status == DocumentStatus.NEEDS_CHECK + + +async def test_period_quarter_enum_in_card(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + document = await make_document(seeded, file=file, job=job, quarter=PeriodQuarter.Q3, is_cumulative=False) + client = await auth_client(KA_RF) + + body = (await client.get(f"/documents/{document.id}")).json() + + assert (body["period_quarter"], body["is_cumulative"]) == ("Q3", False) diff --git a/api/tests/test_api_files_read.py b/api/tests/test_api_files_read.py new file mode 100644 index 0000000..2307188 --- /dev/null +++ b/api/tests/test_api_files_read.py @@ -0,0 +1,281 @@ +"""Чтение файлов организации и карточки файла (spec documents-api «Файлы организации и карточка файла»; design D-11). + +Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT на запрос): файлы, задания и документы создаются +фабриками в той же транзакции. Видимость — через ЮЛ-владельца: ЮЛ Ростовского филиала видит `ka_rf`, не видит +`client_manager` (Краснодарский филиал); `curator_go` без `DOCUMENT_VIEW`. +""" + +import datetime +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +import pytest +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from src.models.files import RecognitionJob, StoredFile +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет +ROSTOV = "Ростовский региональный филиал" +T0 = datetime.datetime(2026, 10, 1, 9, 0, tzinfo=datetime.UTC) + +pytestmark = pytest.mark.integration + + +async def _rostov_entity(session: AsyncSession, name: str = "ООО Чтение файлов") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + return await make_legal_entity(session, name=name, group=group) + + +async def _file( # noqa: PLR0913 - каждое поле файла задаётся тестом явно + session: AsyncSession, + entity: LegalEntity, + *, + minutes: int, + status: FileStatus = FileStatus.PROCESSED, + job_status: RecognitionStatus = RecognitionStatus.done, + metrics: dict[str, Any] | None = None, +) -> tuple[StoredFile, RecognitionJob]: + file, job = await make_stored_file( + session, entity=entity, status=status, job_status=job_status, original_name=f"osv-{minutes}.xls" + ) + file.uploaded_at = T0 + datetime.timedelta(minutes=minutes) + job.metrics = metrics + await session.flush() + return file, job + + +def _files_url(entity_id: uuid.UUID) -> str: + return f"/legal-entities/{entity_id}/files" + + +@contextmanager +def _statements(session: AsyncSession) -> Iterator[list[str]]: + """SQL-операторы, выполненные на соединении теста (запросы приложения идут в ту же транзакцию).""" + bind: Any = session.bind + connection = bind.sync_connection + statements: list[str] = [] + + def before_cursor_execute(*args: Any) -> None: + statements.append(args[2]) + + event.listen(connection, "before_cursor_execute", before_cursor_execute) + try: + yield statements + finally: + event.remove(connection, "before_cursor_execute", before_cursor_execute) + + +# --- «Список после обработки» --------------------------------------------------------------------------------------- + + +async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + processed, processed_job = await _file( + seeded, + entity, + minutes=1, + metrics={"warnings": ["sheet=Лист_1 hierarchy=outline"], "mapping_warnings": ["sheet=Лист_1: сироты"]}, + ) + processed_job.finished_at = T0 + document = await make_document( + seeded, file=processed, job=processed_job, account="66", year=2024, quarter=PeriodQuarter.Q4 + ) + document.locator = "sheet:Лист_1" + failed, failed_job = await _file( + seeded, entity, minutes=2, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + failed_job.error_code = "PARSE_ERROR" + failed_job.error_message = "лист не распознан" + await seeded.flush() + + client = await auth_client(KA_RF) + response = await client.get(_files_url(entity.id)) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["total"], body["limit"], body["offset"]) == (2, 50, 0) + newest, oldest = body["items"] # по убыванию uploaded_at + assert (newest["id"], oldest["id"]) == (str(failed.id), str(processed.id)) + + assert newest["status"] == "ERROR" + assert newest["documents"] == [] + assert newest["job"]["error_code"] == "PARSE_ERROR" + assert newest["job"]["error_message"] == "лист не распознан" + assert newest["job"]["status"] == "error" + + assert oldest["status"] == "PROCESSED" + assert oldest["legal_entity_id"] == str(entity.id) + assert oldest["original_name"] == "osv-1.xls" + assert oldest["job"] == { + "id": str(processed_job.id), + "status": "done", + "error_code": None, + "error_message": None, + "mapping_error": None, + "warnings": ["sheet=Лист_1 hierarchy=outline"], + "mapping_warnings": ["sheet=Лист_1: сироты"], + "finished_at": "2026-10-01T09:00:00Z", + } + assert oldest["documents"] == [ + { + "id": str(document.id), + "document_type": "OSV", + "osv_account_code": "66", + "period_year": 2024, + "period_quarter": "Q4", + "is_cumulative": True, + "status": "RECOGNIZED", + "period_check": None, + "counterparty_check": None, + "locator": "sheet:Лист_1", + } + ] + + +async def test_list_hides_superseded_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + new, _ = await _file(seeded, entity, minutes=2) + old, _ = await _file(seeded, entity, minutes=1, status=FileStatus.SUPERSEDED) + old.superseded_by_file_id = new.id + await seeded.flush() + client = await auth_client(KA_RF) + + default = (await client.get(_files_url(entity.id))).json() + full = (await client.get(_files_url(entity.id), params={"include_superseded": "true"})).json() + + assert [item["id"] for item in default["items"]] == [str(new.id)] + assert default["total"] == 1 + assert [item["id"] for item in full["items"]] == [str(new.id), str(old.id)] + assert full["items"][1]["superseded_by_file_id"] == str(new.id) + assert full["total"] == 2 # noqa: PLR2004 + + +async def test_list_pagination(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + files = [(await _file(seeded, entity, minutes=minute))[0] for minute in range(3)] + client = await auth_client(KA_RF) + + body = (await client.get(_files_url(entity.id), params={"limit": 1, "offset": 1})).json() + + assert (body["total"], body["limit"], body["offset"]) == (3, 1, 1) + assert [item["id"] for item in body["items"]] == [str(files[1].id)] + + +async def test_list_has_no_n_plus_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Число SQL-запросов списка не зависит от числа файлов и документов на странице.""" + client = await auth_client(KA_RF) + assert (await client.get("/me")).status_code == HTTP_OK # первый запрос пишет LOGIN — вне сравнения + one = await _rostov_entity(seeded, "ООО Один файл") + file, job = await _file(seeded, one, minutes=1) + await make_document(seeded, file=file, job=job) + many = await _rostov_entity(seeded, "ООО Много файлов") + for minute in range(4): + file, job = await _file(seeded, many, minutes=minute) + await make_document(seeded, file=file, job=job, account="60") + await make_document(seeded, file=file, job=job, account="62") + + with _statements(seeded) as single: + assert (await client.get(_files_url(one.id))).status_code == HTTP_OK + with _statements(seeded) as multiple: + response = await client.get(_files_url(many.id)) + + assert response.status_code == HTTP_OK + assert len(response.json()["items"]) == 4 # noqa: PLR2004 + assert len(multiple) == len(single), [statement[:80] for statement in multiple] + + +# --- карточка файла -------------------------------------------------------------------------------------------------- + + +async def test_file_card_equals_list_item(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, job = await _file( + seeded, entity, minutes=1, status=FileStatus.ERROR, metrics={"mapping_error": "в результате нет документов ОСВ"} + ) + client = await auth_client(KA_RF) + + card = await client.get(f"/files/{file.id}") + listed = (await client.get(_files_url(entity.id))).json()["items"][0] + + assert card.status_code == HTTP_OK, card.text + assert card.json() == listed + assert card.json()["job"]["id"] == str(job.id) + assert card.json()["job"]["mapping_error"] == "в результате нет документов ОСВ" + assert card.json()["job"]["warnings"] == [] + assert card.json()["job"]["mapping_warnings"] == [] + + +async def test_file_card_without_current_job(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await _file(seeded, entity, minutes=1, status=FileStatus.UPLOADED) + file.current_recognition_job_id = None + await seeded.flush() + client = await auth_client(KA_RF) + + body = (await client.get(f"/files/{file.id}")).json() + + assert body["job"] is None + assert body["documents"] == [] + + +# --- видимость и права ----------------------------------------------------------------------------------------------- + + +async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await _file(seeded, entity, minutes=1) + client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал + + foreign = await client.get(f"/files/{file.id}") + missing = await client.get(f"/files/{uuid.uuid4()}") + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert foreign.json()["detail"]["code"] == "NOT_FOUND" + + +async def test_files_of_foreign_entity_are_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + await _file(seeded, entity, minutes=1) + client = await auth_client(CLIENT_MANAGER) + + foreign = await client.get(_files_url(entity.id)) + missing = await client.get(_files_url(uuid.uuid4())) + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + + +async def test_reading_files_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await _file(seeded, entity, minutes=1) + client = await auth_client(CURATOR_GO) # ГО видит всё, но права DOCUMENT_VIEW нет + + assert (await client.get(_files_url(entity.id))).status_code == HTTP_FORBIDDEN + assert (await client.get(f"/files/{file.id}")).status_code == HTTP_FORBIDDEN + + +async def test_unconfirmed_document_status_is_listed(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Статус документа в карточке файла — как в БД (`NEEDS_CHECK` виден аналитику до подтверждения).""" + entity = await _rostov_entity(seeded) + file, job = await _file(seeded, entity, minutes=1) + await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + client = await auth_client(KA_RF) + + body = (await client.get(f"/files/{file.id}")).json() + + assert [document["status"] for document in body["documents"]] == ["NEEDS_CHECK"] diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py index 6b753b6..bcfeca9 100644 --- a/api/tests/test_factories.py +++ b/api/tests/test_factories.py @@ -2,6 +2,7 @@ import json from decimal import Decimal +from pathlib import Path import pytest from sqlalchemy import select @@ -29,6 +30,7 @@ from tests.factories import ( CommittedJobs, make_company_group, make_document, + make_entries_from_result, make_legal_entity, make_membership, make_regional_office, @@ -198,6 +200,48 @@ async def test_make_document_defaults_and_uniqueness(db_session: AsyncSession) - assert document.is_cumulative is True assert other.status == DocumentStatus.NEEDS_CHECK assert other.id != document.id + # уникальность документа в файле (`uq_document_file_type_account`): второй ОСВ того же счёта не создаётся + with pytest.raises(IntegrityError): + async with db_session.begin_nested(): + await make_document(db_session, file=file, job=job) + + +MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json" + + +@pytest.mark.integration +async def test_make_entries_from_result_builds_tree(seeded: AsyncSession) -> None: + """R-2: документ на лист ОСВ, строки без TOTAL с родителями, `group_schema` задания по `locator`.""" + entity = await make_legal_entity(seeded) + file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + result = json.loads(MINIMAL_RESULT.read_text("utf-8")) + + documents = await make_entries_from_result(seeded, file, job, result) + + assert len(documents) == 1 + document = documents[0] + assert (document.osv_account_code, document.locator, document.status) == ( + "60", + "sheet:Лист_1", + DocumentStatus.NEEDS_CHECK, + ) + rows = ( + ( + await seeded.execute( + select(OsvEntry).where(OsvEntry.document_id == document.id).order_by(OsvEntry.row_number) + ) + ) + .scalars() + .all() + ) + assert [row.row_number for row in rows] == [1, 2] + assert rows[0].parent_entry_id is None + assert rows[1].parent_entry_id == rows[0].id + assert rows[0].account_id is not None # счёт 60 есть в плане счетов seed + assert job.group_schema == [ + {"locator": "sheet:Лист_1", "groups": [{"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}]} + ] + assert job.metrics["mapping_warnings"] == [] @pytest.mark.integration diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 4e7a940..231f6b8 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -44,12 +44,29 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{A}/roles": {"get"}, f"{A}/permissions": {"get"}, f"{A}/roles/{{code}}/permissions": {"put"}, - # primary-files (Срез 2c) - "/api/v1/legal-entities/{legal_entity_id}/files": {"post"}, + # primary-files, documents-api (Срез 2c) + "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, + "/api/v1/files/{file_id}": {"get"}, + "/api/v1/documents/{document_id}": {"get"}, + "/api/v1/documents/{document_id}/entries": {"get"}, + "/api/v1/documents/{document_id}/confirm": {"post"}, } +# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно +CHECKED_PREFIXES = (D, A, "/api/v1/legal-entities", "/api/v1/files", "/api/v1/documents") + # Модели, которые обязаны быть в схеме ровно один раз под коротким именем -SINGLE_MODELS = ["RegionalOfficeOut", "CompanyGroupOut", "LegalEntityOut", "UserOut", "RoleOut", "ImportReportOut"] +SINGLE_MODELS = [ + "RegionalOfficeOut", + "CompanyGroupOut", + "LegalEntityOut", + "UserOut", + "RoleOut", + "ImportReportOut", + "FileOut", + "DocumentOut", + "EntryOut", +] @pytest.fixture(scope="module") @@ -66,8 +83,8 @@ def test_spec_route_is_in_openapi(schema: dict[str, Any], path: str) -> None: def test_no_unexpected_routes_under_directories_and_admin(schema: dict[str, Any]) -> None: """Маршрутов сверх спек нет: новые маршруты сначала попадают в спеку и в этот список.""" - actual = {path for path in schema["paths"] if path.startswith((D, A))} - expected = {path for path in EXPECTED_ROUTES if path.startswith((D, A))} + actual = {path for path in schema["paths"] if path.startswith(CHECKED_PREFIXES)} + expected = {path for path in EXPECTED_ROUTES if path.startswith(CHECKED_PREFIXES)} assert actual == expected for path in actual: assert set(schema["paths"][path]) == EXPECTED_ROUTES[path], path diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 2bb16d4..7c543d1 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -274,9 +274,9 @@ class DocumentService: # маршруты: GET /legal-entities/{id}/files → Page[FileOut]; GET /files/{file_id} → FileOut; GET /documents/{id} → DocumentOut; GET /documents/{id}/entries → Page[EntryOut]; POST /documents/{id}/confirm → DocumentOut ``` -- [ ] **Step 1: Тесты:** сценарии спеки `documents-api` (список с `PROCESSED`+документом и `ERROR`, `include_superseded`, карточка, 404 для чужого, строки деревом на данных из golden через `make_entries_from_result`, `limit=2001` → 422, подтверждение с аудитом, повтор → 409, документ `SUPERSEDED` файла → 409; гонка Review Focus 5: файл переведён в `SUPERSEDED` между чтением и `confirm` — имитация прямым UPDATE перед вызовом → 409); `test_openapi_routes` — новые пути. -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(files): API файлов, документов и строк ОСВ; подтверждение документа`. +- [x] **Step 1: Тесты:** сценарии спеки `documents-api` (список с `PROCESSED`+документом и `ERROR`, `include_superseded`, карточка, 404 для чужого, строки деревом на данных из golden через `make_entries_from_result`, `limit=2001` → 422, подтверждение с аудитом, повтор → 409, документ `SUPERSEDED` файла → 409; гонка Review Focus 5: файл переведён в `SUPERSEDED` между чтением и `confirm` — имитация прямым UPDATE перед вызовом → 409); `test_openapi_routes` — новые пути. +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(files): API файлов, документов и строк ОСВ; подтверждение документа`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 109cf81..57b8859 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -50,7 +50,7 @@ ## 4. API и удаление -- [ ] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты +- [x] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты `GET /legal-entities/{id}/files`, `GET /files/{id}`, `GET /documents/{id}`, `GET /documents/{id}/entries`, `POST /documents/{id}/confirm`; policy через ЮЛ-владельца. Тесты: сценарии спеки `documents-api`; `test_openapi_routes`. - [ ] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, -- 2.47.2 From e2fde902936d3e8d24b9aa611d6eab6066700d47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:39:32 +0400 Subject: [PATCH 23/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20Task=209=20(design=20=E2=84=9633=E2=80=933?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 5e19b55..b996050 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -190,3 +190,6 @@ rows без TOTAL, у каждого не-корня есть родитель, | 30 | 2026-10-05 | R-17 (Task 7): «нарастающая по данным Q1» даёт предупреждение маппинга, поэтому документ `NEEDS_CHECK` (буквально D-7/D-9: `RECOGNIZED` только без предупреждений) | спека; неопределённая нарастающая требует взгляда аналитика | исключить это предупреждение из правила статуса | | 31 | 2026-10-05 | R-18 (Task 7): результат с `job_id`/`file_id` ≠ заданию — неповторяемая ошибка; ошибки S3, кроме «нет объекта», — повторяемые; нелистовые документы (BALANCE/INCOME/OTHER) игнорируются без предупреждения; сироты → предупреждение + `NEEDS_CHECK`; ключ с сегментами `.`/`..`/пустыми отвергается | защита от подмены; ОСВ-only подсрез | убрать проверки | | 32 | 2026-10-05 | R-19 (Task 8): автозамена берёт `pg_advisory_xact_lock('supersede:{legal_entity_id}')`, затем `FOR UPDATE` старых файлов по `id`, документы перечитываются после блокировки (`confirmed` видит зафиксированное подтверждение); документ без года/квартала в автозамене не участвует | в READ COMMITTED два параллельных маппинга не видят документов друг друга и оба остались бы действующими; NULL-период не доказывает «тот же период» | убрать блокировку / включить NULL-периоды | +| 33 | 2026-10-05 | R-20 (Task 9): в `JobBrief` добавлено `mapping_error` (из `metrics.mapping_error`); 409 `DOCUMENT_NOT_CONFIRMABLE` для статуса вне {RECOGNIZED, NEEDS_CHECK}; `FILE_SUPERSEDED` проверяется раньше `ALREADY_CONFIRMED`; документ/файл исчезли за время ожидания блокировки → 404 | файл `ERROR` после неудачного маппинга иначе необъясним по карточке; цель среза — объяснимые статусы | убрать поле/код | +| 34 | 2026-10-05 | R-21 (Task 9): `confirm` берёт блокировки «файл → документ» и перечитывает оба под `FOR UPDATE`; чтение файлов — методы `PrimaryFilesService`, документов — `DocumentService`; задание для `DocumentOut` — текущее задание файла; список файлов `ORDER BY uploaded_at DESC NULLS LAST, id`; у моделей нет `relationship()`, поэтому job/документы грузятся пачками (число запросов не зависит от числа файлов, проверено тестом) | согласовано с порядком блокировок автозамены; Review Focus 5 | переупорядочить блокировки | +| 35 | 2026-10-05 | R-22 (Task 9): `test_openapi_routes` отвергает лишние маршруты под `/legal-entities`, `/files`, `/documents` (Task 10 обязан добавить DELETE в `EXPECTED_ROUTES`) | новый маршрут сначала должен попасть в спеку | ослабить тест | -- 2.47.2 From 15021735e980884216b02f2c34983c35cbc17f6f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:43:38 +0400 Subject: [PATCH 24/35] =?UTF-8?q?feat(files):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20?= =?UTF-8?q?=D1=81=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=D0=B8,=20expires=5Fat=20=D1=83=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=20S3,=20FILE=5FDELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /files/{file_id} (DOCUMENT_EDIT, policy): блокировки «задание → файл → документы», проверки после блокировок (409 FILE_HAS_CONFIRMED_DOCUMENTS, FILE_SUPERSEDES_OTHERS), удаление osv_entry/document/recognition_job/ stored_file (разрыв FK-цикла), expires_at у объектов S3 файла и результатов, аудит FILE_DELETE с before_value. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/endpoints/files/read.py | 27 +- api/src/repositories/files.py | 55 ++- api/src/services/primary_files.py | 53 ++- api/tests/test_api_files_delete.py | 362 ++++++++++++++++++ api/tests/test_openapi_routes.py | 2 +- .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 7 files changed, 497 insertions(+), 10 deletions(-) create mode 100644 api/tests/test_api_files_delete.py diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 0ffe6c4..3a1eaa4 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -1,7 +1,9 @@ """`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла -(spec documents-api «Файлы организации и карточка файла»; design D-11). +(spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла +(spec primary-files «Удаление файла»; design D-10). -Право `DOCUMENT_VIEW` проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий. +Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, +что несуществующий. Список — по убыванию `uploaded_at`, заменённые (`SUPERSEDED`) — только при `include_superseded=true`. """ @@ -18,9 +20,18 @@ from src.schemas.files import FileOut router = APIRouter() VIEW = [Depends(require("DOCUMENT_VIEW"))] +EDIT = [Depends(require("DOCUMENT_EDIT"))] NOT_FOUND: dict[int | str, dict[str, Any]] = { status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} } +DELETE_RESPONSES: dict[int | str, dict[str, Any]] = { + **NOT_FOUND, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS " + "(файл заменил более ранние версии)", + }, +} class FileListQuery(PageParams): @@ -53,3 +64,15 @@ async def list_files( async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileOut: """Карточка файла — как элемент списка файлов организации.""" return await service.get_file(file_id) + + +@router.delete( + "/files/{file_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=EDIT, + responses=DELETE_RESPONSES, + summary="Удалить файл", +) +async def delete_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> None: + """Удаляет строки ОСВ, документы, задания и файл; объекту S3 ставится `expires_at`; аудит `FILE_DELETE`.""" + await service.delete(file_id) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 97c7ddb..aa538f2 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -7,17 +7,18 @@ SQL без бизнес-правил; видимость — через ЮЛ-в Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. """ +import datetime import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, func, select +from sqlalchemy import Select, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.directories import LegalEntity from src.models.enums import FileStatus -from src.models.files import Document, RecognitionJob, StoredFile +from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -127,3 +128,53 @@ async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Docume select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True) ) return (await session.execute(stmt)).scalar_one_or_none() + + +async def lock_jobs(session: AsyncSession, file_id: uuid.UUID) -> list[RecognitionJob]: + """`SELECT recognition_job … FOR UPDATE` всех заданий файла (по `id`) с перечитыванием. Порядок блокировок обмена и + маппинга — «задание → файл», поэтому вызывать до `lock_file`.""" + stmt = ( + select(RecognitionJob) + .where(RecognitionJob.file_id == file_id) + .order_by(RecognitionJob.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list[Document]: + """`SELECT document … FOR UPDATE` всех документов файла (по `id`) с перечитыванием; после `lock_file`.""" + stmt = ( + select(Document) + .where(Document.stored_file_id == file_id) + .order_by(Document.id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: + """На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии).""" + stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists()) + return bool((await session.execute(stmt)).scalar_one()) + + +async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: + """Удаляет строки ОСВ, документы, все задания и сам файл. FK-цикл `stored_file.current_recognition_job_id` ↔ + `recognition_job.file_id` разрывается обнулением ссылки до удаления заданий. Блокировки — у вызывающего.""" + file_id = file.id + file.current_recognition_job_id = None + await session.flush() + document_ids = select(Document.id).where(Document.stored_file_id == file_id) + await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(document_ids))) + await session.execute(delete(Document).where(Document.stored_file_id == file_id)) + await session.execute(delete(RecognitionJob).where(RecognitionJob.file_id == file_id)) + await session.execute(delete(StoredFile).where(StoredFile.id == file_id)) + + +async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None: + """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" + if object_ids: + await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index fce173f..3ede2d3 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -35,7 +35,14 @@ from src.core.storage import ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import VisibilityScope from src.models.directories import LegalEntity -from src.models.enums import AuditAction, AuditSection, FileStatus, RecognitionStatus, S3BucketRole +from src.models.enums import ( + AuditAction, + AuditSection, + DocumentStatus, + FileStatus, + RecognitionStatus, + S3BucketRole, +) from src.models.files import RecognitionJob, S3Object, StoredFile from src.repositories import directories as directories_repo from src.repositories import files as files_repo @@ -51,6 +58,8 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE" STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего +FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" +FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" class StorageUnavailableError(DomainError): @@ -155,6 +164,48 @@ class PrimaryFilesService: (card,) = await self._cards([file]) return card + async def delete(self, file_id: uuid.UUID) -> None: + """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). + + Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы + (confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается + под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`, + `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл, + исчезнувший, пока ждали блокировку, — 404. + + Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий + остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает + ретеншн бакета. `before_value` аудита — карточка файла.""" + visible = await files_repo.get_file(self._session, self._scope, file_id) + if visible is None: + raise NotFoundError(FILE_NOT_FOUND) + jobs = await files_repo.lock_jobs(self._session, file_id) + file = await files_repo.lock_file(self._session, file_id) + if file is None: # удалён параллельно, пока ждали блокировку + raise NotFoundError(FILE_NOT_FOUND) + documents = await files_repo.lock_file_documents(self._session, file_id) + if any(document.status is DocumentStatus.CONFIRMED for document in documents): + raise ConflictError( + FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы" + ) + if await files_repo.supersedes_others(self._session, file_id): + raise ConflictError( + FILE_SUPERSEDES_OTHERS, + "файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется", + ) + current = next((job for job in jobs if job.id == file.current_recognition_job_id), None) + before = file_out(file, current, documents).model_dump(mode="json") + object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + await files_repo.delete_file_rows(self._session, file) + await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now()) + await self._audit.record( + action=AuditAction.FILE_DELETE, + section=AuditSection.PRIMARY_DOCUMENTS, + object_type=OBJECT_TYPE, + object_id=file_id, + before_value=before, + ) + async def _cards(self, files: list[StoredFile]) -> list[FileOut]: jobs = await files_repo.current_jobs(self._session, files) documents = await files_repo.documents_by_file(self._session, [file.id for file in files]) diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py new file mode 100644 index 0000000..5d39bdb --- /dev/null +++ b/api/tests/test_api_files_delete.py @@ -0,0 +1,362 @@ +"""Удаление файла `DELETE /files/{file_id}` (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; +design D-10). + +Основные сценарии — поверх фикстуры `app` (транзакция теста). Гонки с подтверждением и циклом маппинга — на +`committed_*` (временная БД, сессия запроса как в проде). +""" + +import asyncio +import json +import uuid +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.config import get_settings +from src.models.audit_ex import AuditEvent +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus +from src.models.files import Document, RecognitionJob, S3Object, StoredFile +from src.models.osv import OsvEntry +from src.services.result_mapping import process_once +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import ( + make_company_group, + make_document, + make_entries_from_result, + make_legal_entity, + make_result_object, + make_stored_file, +) + +HTTP_NO_CONTENT = 204 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT +RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW +CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +MINIMAL_RESULT = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json" +LOCK_WAIT_SECONDS = 10.0 + +pytestmark = pytest.mark.integration + + +async def _rostov_entity(session: AsyncSession, name: str = "ООО Удаление") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + return await make_legal_entity(session, name=name, group=group) + + +def _minimal() -> dict[str, Any]: + return json.loads(MINIMAL_RESULT.read_text("utf-8")) + + +async def _count(session: AsyncSession, model: type, *where: Any) -> int: + return (await session.execute(select(func.count()).select_from(model).where(*where))).scalar_one() + + +async def _delete_events(session: AsyncSession) -> list[AuditEvent]: + rows = await session.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_DELETE)) + return list(rows.scalars()) + + +# --- успешное удаление ---------------------------------------------------------------------------------------------- + + +async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Удаление ошибочного файла»: 204, записей нет, `expires_at` у объектов, аудит с карточкой в `before_value`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + job.error_code = "PARSE_ERROR" + result_object = await make_result_object(seeded, job=job, storage=FakeStorage(), result={}) + file_id, job_id, s3_id = file.id, job.id, file.s3_object_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 response.content == b"" + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(seeded, RecognitionJob, RecognitionJob.file_id == file_id) == 0 + for object_id in (s3_id, result_object.id): # строки s3_object остаются, им ставится expires_at + s3 = await seeded.get(S3Object, object_id, populate_existing=True) + assert s3 is not None + assert s3.expires_at is not None + [event] = await _delete_events(seeded) + assert event.section is AuditSection.PRIMARY_DOCUMENTS + assert (event.object_type, event.object_id) == ("stored_file", file_id) + assert event.user_id is not None + assert event.before_value is not None + assert event.before_value["id"] == str(file_id) + assert event.before_value["status"] == "ERROR" + assert event.before_value["job"]["id"] == str(job_id) + assert event.before_value["job"]["error_code"] == "PARSE_ERROR" + assert event.after_value is None + + +async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + documents = await make_entries_from_result(seeded, file, job, _minimal()) + assert documents + entry_count = await _count(seeded, OsvEntry, OsvEntry.recognition_job_id == job.id) + assert entry_count > 0 + file_id, job_id = file.id, job.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, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0 + assert await _count(seeded, Document, Document.stored_file_id == file_id) == 0 + assert await _count(seeded, RecognitionJob, RecognitionJob.id == job_id) == 0 + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + [event] = await _delete_events(seeded) + assert event.before_value is not None + assert [item["id"] for item in event.before_value["documents"]] == [str(d.id) for d in documents] + + +@pytest.mark.parametrize("status", [FileStatus.UPLOADED, FileStatus.PROCESSING, FileStatus.SUPERSEDED]) +async def test_delete_file_in_other_statuses( + seeded: AsyncSession, auth_client: AuthClientFactory, status: FileStatus +) -> None: + """Файл в `UPLOADED`/`PROCESSING` и `SUPERSEDED` (ни на что не влияет) удаляется.""" + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=status) + 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 + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0 + + +async def test_delete_superseded_file_keeps_new_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + old.superseded_by_file_id = new.id + await seeded.flush() + old_id, new_id = old.id, new.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{old_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1 + + +# --- отказы --------------------------------------------------------------------------------------------------------- + + +async def test_confirmed_document_blocks_delete(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.NEEDS_CHECK) + await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED) + file_id, job_id = file.id, job.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, RecognitionJob, RecognitionJob.id == job_id) == 1 + assert await _count(seeded, Document, Document.stored_file_id == file_id) == 2 # noqa: PLR2004 + assert await _delete_events(seeded) == [] + s3 = await seeded.get(S3Object, file.s3_object_id, populate_existing=True) + assert s3 is not None + assert s3.expires_at is None + + +async def test_file_that_superseded_others_is_not_deletable( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + entity = await _rostov_entity(seeded) + new, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) + old, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.SUPERSEDED) + old.superseded_by_file_id = new.id + await seeded.flush() + new_id = new.id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{new_id}") + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_SUPERSEDES_OTHERS" + assert await _count(seeded, StoredFile, StoredFile.id == new_id) == 1 + assert await _delete_events(seeded) == [] + + +async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал: ЮЛ Ростова не видит, DOCUMENT_EDIT есть + + foreign = await client.delete(f"/files/{file_id}") + missing = await client.delete(f"/files/{uuid.uuid4()}") + + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + assert await _delete_events(seeded) == [] + + +async def test_delete_requires_document_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(RKA) # свой филиал, только DOCUMENT_VIEW + + assert (await client.get(f"/files/{file_id}")).status_code == 200 # noqa: PLR2004 + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_FORBIDDEN + assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1 + + +async def test_repeated_delete_and_read_after_delete_are_404( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + entity = await _rostov_entity(seeded) + file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR) + file_id = file.id + client = await auth_client(KA_RF) + + assert (await client.delete(f"/files/{file_id}")).status_code == HTTP_NO_CONTENT + again = await client.delete(f"/files/{file_id}") + read = await client.get(f"/files/{file_id}") + + assert again.status_code == read.status_code == HTTP_NOT_FOUND + assert len(await _delete_events(seeded)) == 1 + + +# --- гонки (временная БД) ------------------------------------------------------------------------------------------- + + +async def _committed_file( + maker: async_sessionmaker[AsyncSession], storage: FakeStorage | None = None +) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: + """Файл `PROCESSING` ЮЛ Ростовского филиала с документом `NEEDS_CHECK` (и результатом в `storage`, если задан); + `(file_id, job_id, document_id)`.""" + async with maker() as session, session.begin(): + entity = await _rostov_entity(session, "ООО Гонка удаления") + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done + ) + document = await make_document(session, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + if storage is not None: + await make_result_object( + session, + job=job, + storage=storage, + result=_minimal() | {"job_id": str(job.id), "file_id": str(file.id)}, + ) + return file.id, job.id, document.id + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("удаление не встало в ожидание блокировки файла") + + +async def test_delete_rereads_state_after_locks( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Подтверждение держит файл под `FOR UPDATE` и фиксирует документ `CONFIRMED`; удаление, начатое в это время, + ждёт блокировку, перечитывает документы и отвечает 409 — ничего не удалено.""" + file_id, job_id, document_id = await _committed_file(committed_sessionmaker) + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as confirmer, confirmer.begin(): + await confirmer.execute(select(StoredFile.id).where(StoredFile.id == file_id).with_for_update()) + deletion = asyncio.create_task(client.delete(f"/files/{file_id}")) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not deletion.done() + await confirmer.execute( + update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED) + ) + response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS" + async with committed_sessionmaker() as session: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 1 + assert await _count(session, RecognitionJob, RecognitionJob.id == job_id) == 1 + assert await _delete_events(session) == [] + + +async def test_delete_of_file_removed_while_waiting_is_404( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Файл удалён параллельной транзакцией, пока удаление ждало блокировку: 404, не 500.""" + file_id, job_id, _ = await _committed_file(committed_sessionmaker) + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as other, other.begin(): + await other.execute(select(RecognitionJob.id).where(RecognitionJob.id == job_id).with_for_update()) + deletion = asyncio.create_task(client.delete(f"/files/{file_id}")) + await _wait_for_lock_waiter(committed_sessionmaker) + file = await other.get(StoredFile, file_id) + assert file is not None + file.current_recognition_job_id = None + await other.flush() + await other.execute(text("DELETE FROM document WHERE stored_file_id = :id"), {"id": file_id}) + await other.execute(text("DELETE FROM recognition_job WHERE file_id = :id"), {"id": file_id}) + await other.execute(text("DELETE FROM stored_file WHERE id = :id"), {"id": file_id}) + response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_NOT_FOUND, response.text + async with committed_sessionmaker() as session: + assert await _delete_events(session) == [] + + +async def test_mapping_loop_ignores_deleted_file( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Файл `PROCESSING` с готовым результатом удалён до цикла маппинга: `process_once` не падает, кандидатов нет, + документы и строки для файла не создаются; объект результата получил `expires_at`.""" + storage = FakeStorage() + file_id, job_id, _ = await _committed_file(committed_sessionmaker, storage) + client = await committed_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert await process_once(committed_sessionmaker, storage, get_settings()) is False + async with committed_sessionmaker() as session: + assert await _count(session, StoredFile, StoredFile.id == file_id) == 0 + assert await _count(session, Document, Document.stored_file_id == file_id) == 0 + assert await _count(session, OsvEntry, OsvEntry.recognition_job_id == job_id) == 0 + result_key = f"results/{file_id}/{job_id}/result.json" + expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one() + assert expires is not None diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 231f6b8..4fa95cb 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -46,7 +46,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { f"{A}/roles/{{code}}/permissions": {"put"}, # primary-files, documents-api (Срез 2c) "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, - "/api/v1/files/{file_id}": {"get"}, + "/api/v1/files/{file_id}": {"get", "delete"}, "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 7c543d1..0bd0873 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -291,9 +291,9 @@ class DocumentService: async def delete(self, file_id) -> None # get_file(scope) → 404; есть document CONFIRMED → ConflictError("FILE_HAS_CONFIRMED_DOCUMENTS"); EXISTS stored_file WHERE superseded_by_file_id = id → ConflictError("FILE_SUPERSEDES_OTHERS"); before = FileOut-словарь; DELETE osv_entry (по документам файла), document, recognition_job WHERE file_id (сначала file.current_recognition_job_id = NULL + flush — FK цикл), stored_file; s3_object.expires_at = now (ASSUMPTION(OQ-22)); audit FILE_DELETE before_value=before ``` -- [ ] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками). -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`. +- [x] **Step 1: Тесты:** сценарии спеки (ошибочный файл → 204, записей нет, `expires_at`, аудит; `CONFIRMED` → 409; файл, заменивший другой → 409; `SUPERSEDED` → 204; чужой → 404; `PROCESSED` без подтверждения → 204 со строками). +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(files): удаление файла с ограничениями, expires_at у объекта S3, FILE_DELETE`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 57b8859..02eaae3 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -53,7 +53,7 @@ - [x] 4.1 **API чтения и подтверждение.** `repositories/files.py`, `services/documents.py`, `schemas/documents.py`, эндпоинты `GET /legal-entities/{id}/files`, `GET /files/{id}`, `GET /documents/{id}`, `GET /documents/{id}/entries`, `POST /documents/{id}/confirm`; policy через ЮЛ-владельца. Тесты: сценарии спеки `documents-api`; `test_openapi_routes`. -- [ ] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, +- [x] 4.2 **Удаление файла.** `DELETE /files/{id}`: проверки `CONFIRMED`/`superseded_by`, удаление строк/документов/заданий/файла, `s3_object.expires_at`, аудит `FILE_DELETE`. Тесты: сценарии спеки. ## 5. Сквозной прогон и документы -- 2.47.2 From ea2423dec93a33f83c2c90043b8eddab1f7718cc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:47:10 +0400 Subject: [PATCH 25/35] =?UTF-8?q?OpenSpec:=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20Task=2010=20(design=20=E2=84=9636)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-2c-osv-ingest/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index b996050..7c964e9 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -193,3 +193,4 @@ rows без TOTAL, у каждого не-корня есть родитель, | 33 | 2026-10-05 | R-20 (Task 9): в `JobBrief` добавлено `mapping_error` (из `metrics.mapping_error`); 409 `DOCUMENT_NOT_CONFIRMABLE` для статуса вне {RECOGNIZED, NEEDS_CHECK}; `FILE_SUPERSEDED` проверяется раньше `ALREADY_CONFIRMED`; документ/файл исчезли за время ожидания блокировки → 404 | файл `ERROR` после неудачного маппинга иначе необъясним по карточке; цель среза — объяснимые статусы | убрать поле/код | | 34 | 2026-10-05 | R-21 (Task 9): `confirm` берёт блокировки «файл → документ» и перечитывает оба под `FOR UPDATE`; чтение файлов — методы `PrimaryFilesService`, документов — `DocumentService`; задание для `DocumentOut` — текущее задание файла; список файлов `ORDER BY uploaded_at DESC NULLS LAST, id`; у моделей нет `relationship()`, поэтому job/документы грузятся пачками (число запросов не зависит от числа файлов, проверено тестом) | согласовано с порядком блокировок автозамены; Review Focus 5 | переупорядочить блокировки | | 35 | 2026-10-05 | R-22 (Task 9): `test_openapi_routes` отвергает лишние маршруты под `/legal-entities`, `/files`, `/documents` (Task 10 обязан добавить DELETE в `EXPECTED_ROUTES`) | новый маршрут сначала должен попасть в спеку | ослабить тест | +| 36 | 2026-10-05 | R-23 (Task 10): удаление файла блокирует все его задания (по id) → файл → документы, 409 проверяются после блокировок; файл исчез за время ожидания → 404; `expires_at = now` безусловно у `s3_object` файла и объектов результатов (объекты не разделяются между файлами); FK на `document` из `financial_statement_meta`/`consolidation_document`/`ex_snapshot_item` не чистятся (в Срезе 2c недостижимы; `CONFIRMED`-запрет закрывает консолидации; ББ/ОФР — очищать `financial_statement_meta` в Срезе 3); `FILE_HAS_CONFIRMED_DOCUMENTS` проверяется раньше `FILE_SUPERSEDES_OTHERS` | порядок блокировок задание → файл общий для обмена/маппинга | IntegrityError 500 в Срезе 3 без очистки | -- 2.47.2 From f361fae79be6226a61c990deb2d9f39bfc3aa94b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:55:23 +0400 Subject: [PATCH 26/35] =?UTF-8?q?test(e2e):=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=9E?= =?UTF-8?q?=D0=A1=D0=92=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20compose=20?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20tab?= =?UTF-8?q?ular-parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тест tests/e2e (маркеры integration, e2e, fixtures): полное приложение src.app.app с lifespan на временной БД, реальные NATS и MinIO, tabular-parser из профиля services; ОСВ 66 за 2024 -> PROCESSED, документ 66/2024/Q4, 9 строк, counterparty_check = MATCH, status = RECOGNIZED; без профиля — skip. Makefile: цель e2e, check-full включает e2e, test-integration исключает e2e. README: раздел «Сквозной прогон (e2e)». Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- Makefile | 15 +- README.md | 27 ++- api/tests/e2e/__init__.py | 0 api/tests/e2e/conftest.py | 25 +++ api/tests/e2e/test_osv_ingest_e2e.py | 158 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 4 +- .../changes/slice-2c-osv-ingest/design.md | 3 + openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 8 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 api/tests/e2e/__init__.py create mode 100644 api/tests/e2e/conftest.py create mode 100644 api/tests/e2e/test_osv_ingest_e2e.py diff --git a/Makefile b/Makefile index 08ef79a..37a7da3 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ DBML ?= ../../docs/model/data-model-v5.15.dbml -.PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev +.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -21,8 +21,14 @@ lint: test: cd api && $(BIN)/pytest -m "not integration" +# e2e (профиль services, реальный tabular-parser) идёт отдельной целью `e2e`, поэтому здесь исключён test-integration: - cd api && $(BIN)/pytest -m integration + cd api && $(BIN)/pytest -m "integration and not e2e" + +# Сквозной прогон загрузки ОСВ (Срез 2c): нужны compose-инфраструктура и сервис tabular-parser из профиля `services` +# (README, «Сквозной прогон (e2e)»); без профиля тест пропускается с причиной «профиль services не поднят», код 0 +e2e: + cd api && $(BIN)/pytest -m e2e -rs # Сверка ORM с DBML (spec data-model): пустой вывод и код 0 — совпадение dbml-check: @@ -52,8 +58,9 @@ web-check: # Полная проверка перед PR. Порядок: быстрые без БД (check, dbml-check), затем БД/NATS из compose (test-integration), # затем docker, затем web. Цикл миграций upgrade -> downgrade -> upgrade на чистой БД входит в test-integration -# (tests/test_migrations.py), поэтому отдельным шагом не гоняется: второй запуск только удлинил бы прогон -check-full: check dbml-check check-infra test-integration docker-build web-check +# (tests/test_migrations.py), поэтому отдельным шагом не гоняется: второй запуск только удлинил бы прогон. e2e — после +# test-integration; без профиля `services` пропускается (не падает) +check-full: check dbml-check check-infra test-integration e2e docker-build web-check up: docker compose -f $(COMPOSE_FILE) up -d diff --git a/README.md b/README.md index 62bb0e5..660d3cb 100644 --- a/README.md +++ b/README.md @@ -499,14 +499,15 @@ email без учёта регистра проверяет только сер | `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки, требует `DEBUG=true` | | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | -| `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | +| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | +| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | | `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.15.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | | `make web-check` | фронт: `npm install`, `biome check src`, `tsc --noEmit`, `npm run build` | | `make check-infra` | быстрая проверка, что PostgreSQL и NATS из compose доступны (иначе: `make up`) | -| `make check-full` | полная проверка перед PR: `check` + `dbml-check` + `check-infra` + `test-integration` (в нём цикл миграций) + `docker-build` + `web-check`; нужна поднятая инфраструктура (`make up`) и Docker | +| `make check-full` | полная проверка перед PR: `check` + `dbml-check` + `check-infra` + `test-integration` (в нём цикл миграций) + `e2e` (без профиля `services` пропускается) + `docker-build` + `web-check`; нужна поднятая инфраструктура (`make up`) и Docker | ### Тесты @@ -521,6 +522,28 @@ email без учёта регистра проверяет только сер - **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером `fixtures` без каталога пропускаются с понятной причиной. +### Сквозной прогон (e2e) + +Тест `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 на +отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}` +(объекту S3 ставится `expires_at`), БД снимается. + +Предусловие — сервис tabular-parser из профиля `services` на `localhost:8001`. Без него тест пропускается +(`профиль services не поднят`), `make check-full` при этом зелёный. Инфраструктура уже поднята (`make up`); поднимайте +только парсер, без зависимостей (`--no-deps`), чтобы не пересоздавать PostgreSQL/NATS/MinIO: + +```bash +docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser +curl -s localhost:8001/healthcheck # {"uptime": "...", "version": "...", "nats": "ok"} +make e2e # PASSED +docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser +``` + +Образ собирается из `../tabular-parser` (main соседнего репозитория workspace). + ### Проверка ORM и DBML Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один: diff --git a/api/tests/e2e/__init__.py b/api/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/e2e/conftest.py b/api/tests/e2e/conftest.py new file mode 100644 index 0000000..b127cb5 --- /dev/null +++ b/api/tests/e2e/conftest.py @@ -0,0 +1,25 @@ +"""Предусловия сквозных тестов (Срез 2c, tasks 5.1): сервис tabular-parser из профиля `services` compose. + +Без него e2e не имеет смысла (результат распознавания даёт реальный парсер): тест пропускается с причиной, а +`make check-full` без профиля остаётся зелёным. NATS, MinIO и PostgreSQL берутся из `.env.test` (инфраструктура +compose `make up`); профиль поднимается командой из README («Сквозной прогон (e2e)»). +""" + +import httpx +import pytest + +TABULAR_PARSER_HEALTHCHECK = "http://localhost:8001/healthcheck" +HEALTHCHECK_TIMEOUT = 2.0 +SKIP_REASON = "профиль services не поднят" + + +@pytest.fixture(scope="session") +def tabular_parser_ready() -> None: + """`GET localhost:8001/healthcheck` отвечает 200 и `nats=ok`, иначе — skip «профиль services не поднят».""" + try: + response = httpx.get(TABULAR_PARSER_HEALTHCHECK, timeout=HEALTHCHECK_TIMEOUT) + ready = response.status_code == httpx.codes.OK and response.json().get("nats") == "ok" + except (httpx.HTTPError, ValueError): + ready = False + if not ready: + pytest.skip(f"{SKIP_REASON}: {TABULAR_PARSER_HEALTHCHECK} недоступен или без подключения к NATS") diff --git a/api/tests/e2e/test_osv_ingest_e2e.py b/api/tests/e2e/test_osv_ingest_e2e.py new file mode 100644 index 0000000..62d7e78 --- /dev/null +++ b/api/tests/e2e/test_osv_ingest_e2e.py @@ -0,0 +1,158 @@ +"""Сквозной прогон загрузки ОСВ (Срез 2c, tasks 5.1; design D-13) через compose с РЕАЛЬНЫМ tabular-parser. + +Полное приложение `src.app.app` с настоящим lifespan на отдельной временной БД (миграции + seed), реальные NATS и MinIO +из `.env.test`, `NATS_ENABLED=true`. Путь: `POST /legal-entities/{id}/files` → outbox-публикатор → NATS → +tabular-parser (читает файл из MinIO, пишет результат в MinIO) → inbox (ack, результат) → цикл маппинга → файл +`PROCESSED`, документ и строки ОСВ. Предусловие — профиль `services` (`tests/e2e/conftest.py`); иначе тест пропускается. +Очистка: `DELETE /files/{id}` через API и удаление временной БД (фикстура `committed_sessionmaker`). +""" + +import asyncio +import importlib +import time +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.nats import NatsConnection +from src.models.directories import RegionalOffice +from tests.factories import make_company_group, make_legal_entity + +pytestmark = [pytest.mark.integration, pytest.mark.e2e, pytest.mark.fixtures] + +# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib +app_module = importlib.import_module("src.app") +db_module = importlib.import_module("src.db.db") + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, права DOCUMENT_EDIT/DOCUMENT_VIEW +ROSTOV = "Ростовский региональный филиал" +OWNER_INN = "3528052718" +# Как в шапке ОСВ (fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json): ИНН в шапке нет, поэтому +# counterparty_check = MATCH только по имени владельца файла (D-8) +OWNER_NAME = 'АО "Русский бисквит"' +REAL_OSV_PATH = ("osv", "rb", "2024-FY", "ОСВ_66 за 2024.xls") + +POLL_TIMEOUT = 60.0 +POLL_INTERVAL = 0.5 +LOOP_POLL_SECONDS = 1 # публикатор и цикл маппинга не ждут штатные 5 с между проходами +NATS_READY_TIMEOUT = 10.0 +ENTRY_ROWS = 9 # 10 строк снимка минус TOTAL (D-3: итоговая строка не хранится) +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_NOT_FOUND = 404 + + +class _EngineStub: + """Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки.""" + + async def dispose(self) -> None: + return None + + +@pytest_asyncio.fixture +async def running_app( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Any]: + """Настоящий `src.app.app` внутри lifespan: реальные NATS и MinIO, БД — временная (с seed).""" + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + monkeypatch.setattr(app_module.settings, "OUTBOX_POLL_SECONDS", LOOP_POLL_SECONDS) + monkeypatch.setattr(app_module.settings, "MAPPING_POLL_SECONDS", LOOP_POLL_SECONDS) + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "") + # фабрика сессий приложения: lifespan/inbox/циклы берут её из app_module, маршруты и аудит входа — из db_module + monkeypatch.setattr(app_module, "async_session_maker", committed_sessionmaker) + monkeypatch.setattr(db_module, "async_session_maker", committed_sessionmaker) + monkeypatch.setattr(app_module, "engine", _EngineStub()) + + async with app_module.lifespan(app_module.app): + application = app_module.app + assert application.state.storage is not None, "MinIO недоступен (.env.test): S3 не подключён" + nats: NatsConnection = application.state.nats + if not await nats.wait_ready(timeout=NATS_READY_TIMEOUT): + pytest.fail("NATS недоступен (.env.test)", pytrace=False) + yield application + + +async def _client(application: Any, email: str) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: + response = await anonymous.post("/api/v1/dev/token", json={"email": email}) + assert response.status_code == httpx.codes.OK, response.text + return httpx.AsyncClient( + transport=transport, + base_url="http://test/api/v1", + headers={"Authorization": f"Bearer {response.json()['access_token']}"}, + ) + + +async def _wait_processed(client: httpx.AsyncClient, file_id: uuid.UUID) -> dict[str, Any]: + """Опрос карточки файла до `PROCESSED` (до 60 с); `ERROR` — немедленный отказ с причиной из карточки.""" + deadline = time.monotonic() + POLL_TIMEOUT + card: dict[str, Any] = {} + while time.monotonic() < deadline: + response = await client.get(f"/files/{file_id}") + assert response.status_code == httpx.codes.OK, response.text + card = response.json() + if card["status"] == "PROCESSED": + return card + assert card["status"] != "ERROR", f"файл в ERROR: {card}" + await asyncio.sleep(POLL_INTERVAL) + pytest.fail(f"за {POLL_TIMEOUT} с файл не стал PROCESSED; последняя карточка: {card}", pytrace=False) + + +async def test_real_osv_is_parsed_end_to_end( + tabular_parser_ready: None, # noqa: ARG001 - предусловие: профиль services (skip, если не поднят) + fixtures_dir: Any, + running_app: Any, + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes() + async with committed_sessionmaker() as session, session.begin(): + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name="ГК Русский бисквит", office=office) + entity = await make_legal_entity(session, name=OWNER_NAME, inn=OWNER_INN, group=group) + + async with await _client(running_app, KA_RF) as client: + response = await client.post( + f"/legal-entities/{entity.id}/files", + files=[("files", (REAL_OSV_PATH[-1], content, "application/octet-stream"))], + ) + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert body["rejected"] == [] + (accepted,) = body["accepted"] + file_id = uuid.UUID(accepted["file_id"]) + try: + card = await _wait_processed(client, file_id) + + assert card["job"]["status"] == "done" + assert card["job"]["mapping_error"] is None + (brief,) = card["documents"] + assert (brief["document_type"], brief["osv_account_code"]) == ("OSV", "66") + assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True) + assert brief["counterparty_check"] == "MATCH" + assert brief["status"] == "RECOGNIZED" + + document = (await client.get(f"/documents/{brief['id']}")).json() + assert document["file_id"] == str(file_id) + assert document["legal_entity_id"] == str(entity.id) + assert (document["period_year"], document["period_quarter"]) == (2024, "Q4") + assert (document["counterparty_check"], document["status"]) == ("MATCH", "RECOGNIZED") + + entries = (await client.get(f"/documents/{brief['id']}/entries")).json() + assert entries["total"] == ENTRY_ROWS + assert [row["row_number"] for row in entries["items"]] == sorted( + row["row_number"] for row in entries["items"] + ) + root = entries["items"][0] + assert (root["account_code"], root["level"]) == ("66", 0) + finally: + # очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой + deleted = await client.delete(f"/files/{file_id}") + assert deleted.status_code == HTTP_NO_CONTENT, deleted.text + assert (await client.get(f"/files/{file_id}")).status_code == HTTP_NOT_FOUND diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 0bd0873..fca25ee 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -305,8 +305,8 @@ async def delete(self, file_id) -> None # get_file(scope) → 404; есть do **Interfaces:** тест (маркеры `integration`, `e2e`, `fixtures`): предусловия — `curl localhost:8001/healthcheck` tabular-parser из профиля `services` (`docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build tabular-parser`) и `NATS_ENABLED=true` у тестового приложения; иначе `pytest.skip("профиль services не поднят")`. Шаги: полное приложение `src.app.app` (lifespan с реальными NATS/MinIO из `.env.test`, `NATS_ENABLED=true` через monkeypatch settings) на отдельной БД (`committed_sessionmaker` + seed) → `POST /legal-entities/{id}/files` для ЮЛ, созданного фабрикой с ИНН `3528052718` (АО «Русский бисквит», владелец ОСВ 66) с `rb/2024-FY/ОСВ_66 за 2024.xls` → ждать до 60 с `GET /files/{id}` → `PROCESSED` → документ 66/2024/Q4, 9 строк (10 строк снимка минус TOTAL), `counterparty_check = MATCH`, `status = RECOGNIZED`; очистка: удалить файл через API. -- [ ] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. -- [ ] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. +- [x] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. +- [x] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 7c964e9..5d776f0 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -194,3 +194,6 @@ rows без TOTAL, у каждого не-корня есть родитель, | 34 | 2026-10-05 | R-21 (Task 9): `confirm` берёт блокировки «файл → документ» и перечитывает оба под `FOR UPDATE`; чтение файлов — методы `PrimaryFilesService`, документов — `DocumentService`; задание для `DocumentOut` — текущее задание файла; список файлов `ORDER BY uploaded_at DESC NULLS LAST, id`; у моделей нет `relationship()`, поэтому job/документы грузятся пачками (число запросов не зависит от числа файлов, проверено тестом) | согласовано с порядком блокировок автозамены; Review Focus 5 | переупорядочить блокировки | | 35 | 2026-10-05 | R-22 (Task 9): `test_openapi_routes` отвергает лишние маршруты под `/legal-entities`, `/files`, `/documents` (Task 10 обязан добавить DELETE в `EXPECTED_ROUTES`) | новый маршрут сначала должен попасть в спеку | ослабить тест | | 36 | 2026-10-05 | R-23 (Task 10): удаление файла блокирует все его задания (по id) → файл → документы, 409 проверяются после блокировок; файл исчез за время ожидания → 404; `expires_at = now` безусловно у `s3_object` файла и объектов результатов (объекты не разделяются между файлами); FK на `document` из `financial_statement_meta`/`consolidation_document`/`ex_snapshot_item` не чистятся (в Срезе 2c недостижимы; `CONFIRMED`-запрет закрывает консолидации; ББ/ОФР — очищать `financial_statement_meta` в Срезе 3); `FILE_HAS_CONFIRMED_DOCUMENTS` проверяется раньше `FILE_SUPERSEDES_OTHERS` | порядок блокировок задание → файл общий для обмена/маппинга | IntegrityError 500 в Срезе 3 без очистки | +| 37 | 2026-10-05 | Task 11: `make test-integration` = `pytest -m "integration and not e2e"`, e2e идёт целью `make e2e` и отдельным шагом `check-full` после `test-integration` | e2e несёт маркер `integration` (требование брифа); без исключения он запускался бы в `check-full` дважды | двойной прогон e2e при поднятом профиле (секунды); без профиля — два skip | +| 38 | 2026-10-05 | Task 11: профиль `services` для парсера поднимается как `-p arm-ka … up -d --build --no-deps tabular-parser` (README); предусловие теста — `GET localhost:8001/healthcheck` = 200 и `nats=ok`, иначе skip «профиль services не поднят» | проект `arm-ka` поднят из другого файла compose (worktree среза 1): без `--no-deps` compose мог пересоздать postgres/nats/minio при расхождении конфигурации | пересоздание инфраструктуры и потеря dev-данных | +| 39 | 2026-10-05 | Task 11: e2e подменяет `async_session_maker` в `src.app` и `src.db.db` и `engine` в `src.app` (как `test_exchange_lifespan_nats`), `MAPPING_POLL_SECONDS`/`OUTBOX_POLL_SECONDS` = 1; ЮЛ создаётся с именем `АО "Русский бисквит"`, ИНН `3528052718` — в шапке ОСВ ИНН нет, MATCH честный по имени (D-8) | нужна временная БД вместо рабочей и быстрый прогон; шапка снимка: `organization_name = АО "Русский бисквит"`, `organization_inn = null` | e2e читает/пишет не ту БД (рабочая `armka`) либо ложный MISMATCH | diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 02eaae3..1b77029 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -58,7 +58,7 @@ ## 5. Сквозной прогон и документы -- [ ] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле +- [x] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле `services` (реальный `tabular-parser` против общего NATS/MinIO) — загрузка `rb/2024-FY/ОСВ_66 за 2024.xls` через API ядра, ожидание `PROCESSED` (≤ 60 с), проверка документа и 9 строк (10 строк снимка минус TOTAL); без профиля — skip с причиной. `make e2e` цель; `check-full` его включает (skip допустим). -- 2.47.2 From 96525f1aceb695867483a1b776beae0f0dff9c5c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 23:58:16 +0400 Subject: [PATCH 27/35] =?UTF-8?q?docs(e2e):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B7=D0=B0=20recognition.result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e подписывается на реальный recognition.result в queue group arm-core: другой экземпляр ядра (контейнер arm-core профиля services, локальный сервер с NATS_ENABLED=true) заберёт часть ack/результатов. README и docstring теста требуют остановить такие экземпляры; комментарий: MATCH достигается по имени (ИНН в шапке нет). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 6 ++++++ api/tests/e2e/test_osv_ingest_e2e.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 660d3cb..e36c995 100644 --- a/README.md +++ b/README.md @@ -544,6 +544,12 @@ docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services st Образ собирается из `../tabular-parser` (main соседнего репозитория workspace). +**Перед e2e остановите другие экземпляры ядра, подписанные на `recognition.result`** (контейнер `arm-core` из профиля +`services`, локальный `make run`/`run_server.py` с `NATS_ENABLED=true`): тест подписывается на реальный subject в боевой +queue group `arm-core`, подписчики группы получают сообщения по очереди (round-robin), и ack/результат парсера уйдут +чужому экземпляру (он отбросит их как `unknown_job`, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только +`tabular-parser` (`--no-deps`), как в команде выше. + ### Проверка ORM и DBML Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один: diff --git a/api/tests/e2e/test_osv_ingest_e2e.py b/api/tests/e2e/test_osv_ingest_e2e.py index 62d7e78..500a28f 100644 --- a/api/tests/e2e/test_osv_ingest_e2e.py +++ b/api/tests/e2e/test_osv_ingest_e2e.py @@ -5,6 +5,10 @@ tabular-parser (читает файл из MinIO, пишет результат в MinIO) → inbox (ack, результат) → цикл маппинга → файл `PROCESSED`, документ и строки ОСВ. Предусловие — профиль `services` (`tests/e2e/conftest.py`); иначе тест пропускается. Очистка: `DELETE /files/{id}` через API и удаление временной БД (фикстура `committed_sessionmaker`). + +ВНИМАНИЕ: приложение подписывается на реальный `recognition.result` в боевой queue group `arm-core`; если в это время +работает другой экземпляр ядра (контейнер `arm-core` профиля `services`, локальный сервер с `NATS_ENABLED=true`), +часть ack/результатов уйдёт ему и тест упадёт по таймауту. Перед прогоном остановите такие экземпляры (README). """ import asyncio @@ -135,6 +139,7 @@ async def test_real_osv_is_parsed_end_to_end( (brief,) = card["documents"] assert (brief["document_type"], brief["osv_account_code"]) == ("OSV", "66") assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True) + # MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой assert brief["counterparty_check"] == "MATCH" assert brief["status"] == "RECOGNIZED" -- 2.47.2 From f9b8a214107b8c7b6a1af6485a416e5861909bd9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:11:52 +0400 Subject: [PATCH 28/35] =?UTF-8?q?docs:=20README/CLAUDE.md=20=D0=A1=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=202c,=20tasks.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: разделы «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и замена», «Ограничения Среза 2c»; настройки UPLOAD_MAX_BYTES/MAPPING_POLL_SECONDS/MAPPING_MAX_ATTEMPTS; каталог кодов ошибок файлов и документов. CLAUDE.md: модули Среза 2c, три фоновых цикла, порядок блокировок, маркер e2e. OpenSpec: tasks.md (все [x], кроме 5.3; формулировки 1.2/2.1/3.1 по коду), дельта documents-api (job.mapping_error, DOCUMENT_NOT_CONFIRMABLE), design.md — решения №40–50. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 23 ++- README.md | 177 +++++++++++++++++- .../plans/2026-10-05-slice-2c-osv-ingest.md | 8 +- .../changes/slice-2c-osv-ingest/design.md | 11 ++ .../specs/documents-api/spec.md | 16 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 11 +- 6 files changed, 228 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dbcf759..746f9ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,15 +13,17 @@ `venv` (uv, Python 3.11, без `raisa_*`), `lint` (ruff + mypy), `test` (без БД), `test-integration` (PostgreSQL/NATS из compose, включает цикл миграций), `check` (= lint + test, Docker не нужен), `up` (compose из `../docs/dev/`), `migrate`, `seed` / `seed-dev` (справочники / + dev-пользователи), `dbml-check` (сверка ORM↔DBML), `migration-cycle` - (только цикл миграций), `check-infra`, `docker-build`, `web-check` (biome + tsc + build), `check-full` (check + - dbml-check + check-infra + test-integration + docker-build + web-check; **перед PR**, вывод — в описание PR). + (только цикл миграций), `e2e` (сквозной прогон загрузки ОСВ с реальным tabular-parser, без профиля `services` — skip), + `check-infra`, `docker-build`, `web-check` (biome + tsc + build), `check-full` (check + dbml-check + check-infra + + test-integration + e2e + docker-build + web-check; **перед PR**, вывод — в описание PR). Вручную — из `api/`: `python run_server.py`, `alembic upgrade head`, `pytest`; из `web/`: `npm run start|build`. Бутстрап в банке — `setup/setup.sh` (pip/npm через Nexus). - **Раскладка `api/src/`**: `src/api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — `core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: `domain/` (чистые функции по §§ правил, без БД и сессий: `policy.py` — видимость справочников §10.1, `directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат - ИНН; для вычислений консолидации — Срез 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, + ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки + ОСВ (§1.3, §1.5, §9.3); для вычислений консолидации — Срез 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. - **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; `storage`/`nats` — из `request.app.state`. **Сессия** — только `session: SessionDep` (`src/api/dependencies/db.py` = `Depends(get_async_session, scope="function")`): одна @@ -41,7 +43,8 @@ (401/403/413) и ошибки БД — `{"detail": "<строка>"}`, 422 валидации тела — массив. Объект вне видимости пользователя — 404 с тем же телом, что у несуществующего (не 403). Каталог кодов — README, «Ошибки API». **Логи** — loguru. - **Тесты** — pytest-asyncio (auto, session loop), `APP_ENV=test`, `ENV_FILE=.env.test`; маркеры `integration` (нужны - PostgreSQL/NATS) и `fixtures` (реальные данные из `../fixtures`); БД сессии — свежая `armcore_pytest_` с миграциями, + PostgreSQL/NATS), `fixtures` (реальные данные из `../fixtures`) и `e2e` (поверх `integration`: нужен tabular-parser из + профиля `services`, иначе skip; `make test-integration` его исключает, гонять — `make e2e`); БД сессии — свежая `armcore_pytest_` с миграциями, удаляется в конце; `domain/` — без БД. - **Линтеры** — backend `ruff` + `mypy` (`api/pyproject.toml`); frontend `biome` (ширина 140, одинарные кавычки) + `tsc`. - **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), react-router 7; `layouts/` — страницы @@ -64,6 +67,18 @@ публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 × `OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` / `result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут. +- **Первичные документы (Срез 2c)** — `services/primary_files.py` (загрузка `upload_one`: дедуп по `(ЮЛ, sha256)` под + advisory-lock, SAVEPOINT на файл, `delete`), `services/file_status.py` (единственное место смены `stored_file.status` + по ТЕКУЩЕМУ заданию; `PROCESSED`/`SUPERSEDED` обменом не перезаписываются), `services/result_mapping.py` (цикл маппинга: + `pick_candidate` → проверка ключа результата (OQ-70) → чтение S3 вне транзакции → `map_file` в одной транзакции → + автозамена `FILE_REPLACE`), `services/documents.py` (карточки, `confirm`), `domain/osv_mapping.py` (чистые функции: + `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, вахтёр, публикатор, маппер, + `confirm`, `delete`) — иначе взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`, + `FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется + (OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится. - **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции; тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`). В маршрутах — `RequestAudit` (`get_request_audit`: пользователь, IP, user-agent). diff --git a/README.md b/README.md index e36c995..f0df7f4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + администрирование пользователей и ролей, экран `/admin`, аудит. Спецификация — `openspec/changes/slice-1-directories/`; разделы ниже: «API справочников», «Импорт из ЕЦП CRM», «Администрирование и экран /admin», «Ошибки API». +Срез 2c («загрузка ОСВ и маппинг»): схема по DBML v5.15, загрузка файлов `POST /legal-entities/{id}/files`, статусы файла +по заданию распознавания, цикл маппинга результата парсера в `document` и `osv_entry`, автозамена версий, API файлов, +документов и строк, подтверждение и удаление. Спецификация — `openspec/changes/slice-2c-osv-ingest/`; разделы ниже: +«Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и +замена», «Ограничения Среза 2c», «Сквозной прогон (e2e)». + ## Предварительные требования - **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит @@ -384,6 +390,157 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о - **Аудит.** `USER_CHANGE` (создание, правка полей, деактивация) и `USER_ROLE_CHANGE` — раздел `USERS`; `ROLE_PERMISSION_CHANGE` — раздел `ACCESS`; `before_value`/`after_value` — прежнее и новое состояние. +## Первичные документы: загрузка + +Срез 2c: файл первичной отчётности (ОСВ) попадает в ядро, уходит на распознавание, а результат разбирается в документы +и строки ОСВ. Тип документа и период при загрузке **не спрашиваются** (макет `40`): их определяет парсер по содержимому +файла, ядро только раскладывает результат. Путь файла: + +``` +POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD + → публикатор (outbox) → NATS recognition.tabular.request → tabular-parser (читает S3, пишет results/…) + → recognition.result → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED) + → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm +``` + +Загрузка — `multipart/form-data`, одно или несколько полей `files`; право `DOCUMENT_EDIT`, ЮЛ в области видимости +пользователя (чужое — 404, как и несуществующее). Поддерживаются `xls` (BIFF8), `xlsx`, `xlsb`, `pdf`, `png`, `jpg` +(`.jpeg` — как `jpg`): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (`xls/xlsx/xlsb`) +идут в конвейер `tabular` (3 попытки), изображения и PDF — в `image` (1 попытка). Файл больше `UPLOAD_MAX_BYTES` +(50 МиБ) отклоняется, тот же sha256 в пределах одного ЮЛ — тоже (`DUPLICATE_FILE`, в ответе `file_id` прежнего файла; +заменённый `SUPERSEDED` файл дублем не считается, у другого ЮЛ тот же файл принимается). + +Файлы обрабатываются по одному (в SAVEPOINT одной транзакции запроса), ответ — частичный успех: `201`, если принят +хотя бы один, иначе `422`. Объект кладётся в S3 по ключу `uploads/{file_id}/{job_id}/{безопасное имя}`; имя файла +очищается от путей и управляющих символов. + +```bash +# ЮЛ — любое видимое пользователю (создайте через POST /directories/legal-entities или импортом ЕЦП, см. выше); +# название должно совпадать с шапкой ОСВ, иначе counterparty_check = MISMATCH и документ NEEDS_CHECK +TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \ + -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') +LE=$(curl -s 'localhost:8000/api/v1/directories/legal-entities?q=бисквит' -H "Authorization: Bearer $TOKEN" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["items"][0]["id"])') +curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "Authorization: Bearer $TOKEN" \ + -F 'files=@../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls' +# {"accepted":[{"file_id":"…","job_id":"…","name":"ОСВ_66 за 2024.xls"}],"rejected":[]} +curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN" # статус, задание, документы +``` + +Нужны запущенное ядро с `NATS_ENABLED=true`, MinIO и tabular-parser (профиль `services`, см. «Сквозной прогон»). +Без подключённого S3 загрузка отвечает `503 STORAGE_UNAVAILABLE`. + +## Статусы файла и документа + +**Файл** (`stored_file.status`) меняется только по **текущему** заданию (`current_recognition_job_id`): результат, ack, +таймаут или dlq устаревшего задания файл не трогают (`services/file_status.py`). + +| Статус | Когда | +|---|---| +| `UPLOADED` | файл принят, задание в outbox | +| `PROCESSING` | сервис подтвердил (ack) либо пришёл результат `done`/`needs_check` (если ack потерян — core NATS без JetStream) | +| `PROCESSED` | маппинг завершён: документы и строки созданы | +| `ERROR` | задание `error`, `timeout` (вахтёр, `TIMEOUT`), `dlq` или неповторяемая/исчерпавшая попытки ошибка маппинга; причина — в карточке файла (`job.error_code`/`error_message`, `job.mapping_error`) | +| `SUPERSEDED` | файл заменён более новым с теми же документами (автозамена) | + +`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`. + +**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ: + +- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу + (`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет; +- `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта + в файле, строки без родителя, нарастающая по данным Q1; +- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит); +- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (файл без разбираемой ОСВ → файл `ERROR`). + +`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; +`period_check` — `UNKNOWN` до версии консолидации (Срез 4). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. + +## Цикл маппинга + +Фоновая задача lifespan (`services/result_mapping.py:run_mapping_loop`, включается вместе с `NATS_ENABLED`) отделена от +inbox: inbox только фиксирует результат и регистрирует файл результата в `s3_object`, **не читает его**. Цикл с периодом +`MAPPING_POLL_SECONDS` берёт одно задание `done`/`needs_check`, чей файл `PROCESSING` и для которого оно текущее +(`FOR UPDATE SKIP LOCKED`), затем: + +1. проверяет ключ результата: бакет хранилища и префикс `results/{file_id}/{job_id}/` (OQ-70; `.`/`..`/пустые сегменты + отвергаются); +2. читает объект из S3 **вне транзакции** и валидирует `RecognitionResult` v1.x (`job_id`/`file_id` должны совпасть с заданием); +3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт + `document` на каждый лист ОСВ и пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`, + `account_id`, `counterparty_id`), пишет `recognition_job.group_schema` и `metrics.mapping_warnings`, переводит файл + в `PROCESSED` и выполняет автозамену (см. «Удаление и замена»). + +Ошибки: **неповторяемые** (ключ вне папки задания, нет объекта, результат другого задания, невалидный результат, нет +листов ОСВ) сразу переводят файл в `ERROR` с `metrics.mapping_error`; **повторяемые** (БД, S3) откатывают транзакцию, +увеличивают `metrics.mapping_attempts`, цикл спит `MAPPING_POLL_SECONDS` и пробует снова; после `MAPPING_MAX_ATTEMPTS` +— `ERROR`. Пока попытки не исчерпаны, файл остаётся `PROCESSING`; повторный маппинг начинается с очистки строк файла, дублей нет. + +| Переменная | По умолчанию | Назначение | +|---|---|---| +| `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла при загрузке; больше — `FILE_TOO_LARGE` в `rejected` | +| `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга (и пауза после повторяемой неудачи) | +| `MAPPING_MAX_ATTEMPTS` | `3` | число попыток маппинга файла до `ERROR` | + +Что именно делает маппинг (нормализация кодов счетов, резолв контрагента, статус документа, нарастающая по Q1) — +`../docs/context/05-domain-rules.md` §1.3, §1.5, §3 п. 4, §9.2–9.5; код — `src/domain/osv_mapping.py` +(чистые функции) и `src/repositories/counterparties.py` (индекс ЮЛ для резолва, без policy видимости). + +## API файлов и документов + +Все маршруты — под `/api/v1`; объект вне видимости пользователя — 404 с тем же телом, что у несуществующего. + +| Метод и путь | Право | Что делает | +|---|---|---| +| `POST /legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка файлов (multipart `files`), ответ `{accepted[], rejected[]}` | +| `GET /legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | файлы ЮЛ, новые первыми (`limit`/`offset`; `include_superseded=true` — с заменёнными) | +| `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings`, `mapping_warnings`), документы | +| `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` | + +Страницы — `{items, total, limit, offset}`. Суммы строк — десятичные значения без округления, как в результате парсера. +Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`, +`STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, +`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, +`DOCUMENT_CONFIRM` (раздел `PRIMARY_DOCUMENTS`). + +## Удаление и замена + +**Автозамена.** После успешного маппинга ядро ищет другие файлы того же ЮЛ (не `SUPERSEDED`), у которых есть документ +с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и переводит их в `SUPERSEDED` +(`superseded_by_file_id` — новый файл); новый файл получает `version_no` = максимум заменённых + 1. Документы и строки +заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с `CONFIRMED` документом (решение +владельца 05.10.2026); системное событие `FILE_REPLACE` (`user_id` пуст) фиксирует перечень документов и `confirmed=true`. +Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний +уходит в `SUPERSEDED` целиком (OQ — см. `07-open-questions.md`). Автозамены одного ЮЛ идут под advisory-блокировкой. + +**Удаление.** `DELETE /files/{id}` удаляет строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3 +(`uploads`, `results`) `expires_at = now()` (физически объект не удаляется — ретеншн OQ-22) и пишет `FILE_DELETE` +с карточкой файла в `before_value`. Нельзя удалить файл с документом `CONFIRMED` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`) +и файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке +«задания → файл → документы». + +## Ограничения Среза 2c + +- Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`; + `audit_event` пишут только загрузка, автозамена, удаление, подтверждение. +- Объекты S3 при удалении не стираются (OQ-22): растёт бакет; e2e оставляет ~2 объекта за прогон. +- Маппер Среза 2c разбирает только листы ОСВ (`osvDocument`); ББ/ОФР и результаты `scan-gateway` — следующие срезы, + прочие документы результата (BALANCE/INCOME/OTHER) игнорируются без предупреждения. +- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД» + получают `account_root = NULL`; `account_id` ищется точным совпадением с планом счетов seed, субсчетов в seed + неполный набор — часть строк остаётся с `account_id = NULL`. +- ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу. +- ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга, + поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца). +- `period_check` — `UNKNOWN` до Среза 4; удаление файла после консолидаций (FK на `document` из ББ/ОФР и версий) + в этом срезе не рассматривается; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). +- Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); + при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства. + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -431,8 +588,17 @@ email без учёта регистра проверяет только сер | `ALIAS_TAKEN` | 409 | алиас закреплён за другим ЮЛ (`legal_entity_id`) | | `EMAIL_EXISTS` | 409 | пользователь с таким email уже есть | | `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` | +| `UNSUPPORTED_FILE` | 422 | загрузка: тип не из xls/xlsx/xlsb/pdf/png/jpg, расширение не совпало с сигнатурой, пустое имя; строка `rejected[]` ответа загрузки (при импорте — файл целиком) | +| `FILE_TOO_LARGE` | 422 | загрузка: файл больше `UPLOAD_MAX_BYTES`; строка `rejected[]` | +| `DUPLICATE_FILE` | 422 | загрузка: тот же sha256 у файла этого ЮЛ (не `SUPERSEDED`); строка `rejected[]` с `file_id` прежнего файла | +| `STORAGE_UNAVAILABLE` | 503 / 422 | S3 не подключено — 503 на весь запрос; сбой записи одного файла — строка `rejected[]` (если отклонены все — 422) | +| `ALREADY_CONFIRMED` | 409 | подтверждение: документ уже `CONFIRMED` | +| `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией | +| `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | +| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | +| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | -Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой +Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`; при загрузке первичных файлов лимит `UPLOAD_MAX_BYTES` отдаётся не 413, а строкой `rejected[]` с кодом `FILE_TOO_LARGE`. Недостаточно прав — 403 строкой (`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. Под `/api/v1` SPA не отвечает: неверный метод на существующий маршрут — 405 `{"detail": "метод не поддерживается этим маршрутом"}` с заголовком `Allow` (например, `POST /api/v1/me`), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 `{"detail": "Not Found"}`. @@ -472,6 +638,9 @@ email без учёта регистра проверяет только сер | `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации | | `OUTBOX_MAX_REPUBLISH` | `3` | число повторных публикаций до перевода в `dlq` | | `IMPORT_MAX_BYTES` | `20971520` (20 МиБ) | предельный размер файла импорта справочников; больше — 413 | +| `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла первичной отчётности при загрузке; больше — `FILE_TOO_LARGE` в `rejected` | +| `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга результатов распознавания | +| `MAPPING_MAX_ATTEMPTS` | `3` | число попыток маппинга файла до `ERROR` | | `AUTH_DEV_ISSUER` | `false` | dev-эмитент токенов: работает **только** при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` | | `AUTH_DEV_SECRET` | пусто | секрет HS256 dev-эмитента (не короче 32 байт); только для разработки | | `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` | пусто | проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM) | @@ -589,9 +758,11 @@ api/ models/ ORM-модели, 1:1 с DBML repositories/ доступ к данным schemas/ pydantic-схемы запросов и ответов - services/ бизнес-логика + services/ бизнес-логика; primary_files (загрузка/удаление), result_mapping (цикл маппинга и автозамена), + file_status (статус файла по заданию), documents (карточки, подтверждение), recognition_exchange (обмен) common/ доменные ошибки (`{detail: {code, message}}`), часы - domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn + domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn, + file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3) tests/ pytest (юнит и integration/fixtures по маркерам) web/ фронтенд (React 18 + TypeScript, rsbuild, antd): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка web/dist -> api/web/ setup/ бутстрап шаблона через Nexus банка (pip.conf, setup.sh) diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index fca25ee..3c2cf11 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -316,10 +316,10 @@ async def delete(self, file_id) -> None # get_file(scope) → 404; есть do - Modify: `README.md` (разделы «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга» с настройками, «API файлов и документов», «Удаление и замена», «Ограничения»), `CLAUDE.md` (DBML v5.15, `domain/osv_mapping.py`, `services/primary_files.py`, `services/result_mapping.py`, три фоновых цикла, маркер `e2e`), `openspec/changes/slice-2c-osv-ingest/tasks.md` (все `[x]` кроме 5.3), `openspec/changes/slice-2c-osv-ingest/design.md` (рулинги контроллера) - Modify (worktree docs): `context/05-domain-rules.md` (§1.3, §1.5, §3 п. 4, §9.2–9.3, новый §9.5 по факту), `context/03-architecture.md` (цикл маппинга), `context/07-open-questions.md` (закрыть OQ-67/69/70/59/109; новые OQ: удаление после консолидаций, ретеншн при удалении), `context/09-delivery-plan.md` (2c по факту), `dev/qa-guide.md` (раздел «Загрузка ОСВ и просмотр строк через API»: curl-сценарий, ожидания на `ОСВ_66`), `openspec/changes/slice-2c-osv-ingest/tasks.md` 2.1 → `[x]` -- [ ] **Step 1:** таблица «требование спеки → сценарий → тест» в отчёте задачи; пропуски — добавить тесты. -- [ ] **Step 2:** документы; `openspec validate --strict` в обоих репозиториях. -- [ ] **Step 3:** `make check-full` (compose поднят; профиль `services` — по возможности, иначе e2e skip) → зелёный; вывод — в ledger. -- [ ] **Step 4: Commit** здесь `docs: README/CLAUDE.md Среза 2c, tasks.md`; в docs `docs: §1.3/§1.5/§3/§9 по факту маппинга, архитектура (цикл маппинга), OQ, план срезов, qa-guide`. +- [x] **Step 1:** таблица «требование спеки → сценарий → тест» в отчёте задачи; пропуски — добавить тесты. +- [x] **Step 2:** документы; `openspec validate --strict` в обоих репозиториях. +- [x] **Step 3:** `make check-full` (compose поднят; профиль `services` — по возможности, иначе e2e skip) → зелёный; вывод — в ledger. +- [x] **Step 4: Commit** здесь `docs: README/CLAUDE.md Среза 2c, tasks.md`; в docs `docs: §1.3/§1.5/§3/§9 по факту маппинга, архитектура (цикл маппинга), OQ, план срезов, qa-guide`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 5d776f0..a23dc56 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -197,3 +197,14 @@ rows без TOTAL, у каждого не-корня есть родитель, | 37 | 2026-10-05 | Task 11: `make test-integration` = `pytest -m "integration and not e2e"`, e2e идёт целью `make e2e` и отдельным шагом `check-full` после `test-integration` | e2e несёт маркер `integration` (требование брифа); без исключения он запускался бы в `check-full` дважды | двойной прогон e2e при поднятом профиле (секунды); без профиля — два skip | | 38 | 2026-10-05 | Task 11: профиль `services` для парсера поднимается как `-p arm-ka … up -d --build --no-deps tabular-parser` (README); предусловие теста — `GET localhost:8001/healthcheck` = 200 и `nats=ok`, иначе skip «профиль services не поднят» | проект `arm-ka` поднят из другого файла compose (worktree среза 1): без `--no-deps` compose мог пересоздать postgres/nats/minio при расхождении конфигурации | пересоздание инфраструктуры и потеря dev-данных | | 39 | 2026-10-05 | Task 11: e2e подменяет `async_session_maker` в `src.app` и `src.db.db` и `engine` в `src.app` (как `test_exchange_lifespan_nats`), `MAPPING_POLL_SECONDS`/`OUTBOX_POLL_SECONDS` = 1; ЮЛ создаётся с именем `АО "Русский бисквит"`, ИНН `3528052718` — в шапке ОСВ ИНН нет, MATCH честный по имени (D-8) | нужна временная БД вместо рабочей и быстрый прогон; шапка снимка: `organization_name = АО "Русский бисквит"`, `organization_inn = null` | e2e читает/пишет не ту БД (рабочая `armka`) либо ложный MISMATCH | +| 40 | 2026-10-05 | Task 1: тест гонки email (`test_user_email_race_with_other_case_is_not_caught`) инвертирован в `..._is_caught` (409 `EMAIL_EXISTS`); `update_user` email не меняет (в `UPDATE_FIELDS` его нет); прежний `test_downgrade_one_step` переименован в `test_downgrade_to_v5_13`, имя отдано сценарию «Откат на один шаг» | при хранении email в нижнем регистре UNIQUE ловит гонку другого регистра — прежний тест фиксировал уже снятое ограничение; сценарий спеки именует откат на один шаг | тест инвертируется обратно, переименование | +| 41 | 2026-10-05 | Task 2: `make_stored_file` выводит pipeline задания из `file_type`; `make_document(job=…)` использует `job` только как assert `job.file_id == file.id`; `CommittedJobs.cleanup()` обнуляет `current_recognition_job_id` и удаляет `osv_entry` также по `recognition_job_id`; маркер `e2e` описан в `pytest.ini` | у `document` нет FK на задание; иначе удаление задания, на которое ссылается файл, нарушает FK | поправить фабрики | +| 42 | 2026-10-05 | Task 4: тип задания в `file_status` — Protocol `JobRef` (`id`, `file_id`); пакетная `set_file_status_for_jobs` для вахтёра; `ERROR` ставится из любого статуса, кроме `PROCESSED`/`SUPERSEDED`; `updated_at` меняется только при реальной смене; ack для задания не в `published` файл не трогает; в логах обмена поля `file_processing`/`file_changed`/`files_error` | в dlq-пути публикатора ORM-объекта под `FOR UPDATE` нет, только строка выборки; все переходы файла — в одном модуле | сигнатуры, одна ветка | +| 43 | 2026-10-05 | Task 5: `opening_matches` — граница ±0.01 включительно, дебет и кредит попарно, `None = 0`; `normalize_account_code` делает `strip()`, `account_root` проверяет ASCII-цифры (`[0-9]+`); пустая строка `account_code`/`data_type` — отсутствие (`None` в производных), сам `account_code` хранится как пришёл | детали D-6/D-9, которых спека не задаёт | правка функций, тесты | +| 44 | 2026-10-05 | Task 6: `CounterpartyIndex` получил 4-е поле `aliases_by_entity` (нужно `owner_names`); `legal_entity.inn` ключуется через `normalize_inn`; пустое/пробельное имя → `None`; `owner_names` исключает `""` (имя ЮЛ из одних ОПФ) | не делать третий запрос; пустое имя шапки не должно давать ложный MATCH | поле индекса, одна строка | +| 45 | 2026-10-05 | Task 7: `pick_candidate(session, *, max_attempts)` с `ORDER BY finished_at NULLS LAST, id`; `process_once(..., now: Clock | None)`; сироты (`MappedSheet.orphans`) → предупреждение `sheet=: строки без родителя …` → `NEEDS_CHECK`, строка сохраняется с `parent_entry_id = NULL`; при нескольких Q1-документах D-9 берёт последний по `created_at`; `group_schema` задания содержит все листы ОСВ (в т.ч. лист-повтор счёта); после `PROCESSED` — `session.refresh(file)`; `False` от `set_file_status_for_job` под блокировкой — `RuntimeError` | детали, не заданные спекой; Core UPDATE не синхронизирует сессию | сигнатуры, форматы предупреждений | +| 46 | 2026-10-05 | Task 8: description `FILE_REPLACE` — `{document_type} {osv_account_code} {year} {quarter} ({status})`, список отсортирован, `uploaded_by` NULL → «загрузил неизвестен»; при замене обновляются `version_no` и `updated_at` нового файла; в golden все 82 снимка мапятся для одного ЮЛ, поэтому снимки с одним счётом и периодом заменяют друг друга (инвариант проверяется по каждому файлу, статус `PROCESSED` или `SUPERSEDED`); `account_id` проверяется у строк, чьи коды есть в плане счетов seed | формат description в спеке свободен; golden не должен зависеть от замены; 19 кодов вне seed (O-3) | текст события, критерий golden | +| 47 | 2026-10-05 | Task 9: репозиторий файлов без `joinedload`/`selectinload` (в моделях нет `relationship()`) — пакетные `current_jobs`/`documents_by_file`; сортировки: файлы `uploaded_at DESC NULLS LAST, id`, документы `document_type, osv_account_code, locator, id`; `DocumentOut` берёт текущее задание файла; `group_schema` без элемента с `locator` документа → `null`, `groups` не список → `[]`; нестроковые предупреждения отбрасываются; `FILE_SUPERSEDED` приоритетнее `ALREADY_CONFIRMED`; права в тестах — `rka`/`curator_go` (у `CLIENT_MANAGER` по seed есть оба права); `make_entries_from_result` импортирует приватные `_entry_values`/`_group_schema` маппера | в моделях нет связей; детерминизм порядка; отсутствие дублирования вставки | рефакторинг репозитория, порядок 409 | +| 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 | diff --git a/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md index ccd12fb..ea3b355 100644 --- a/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md +++ b/openspec/changes/slice-2c-osv-ingest/specs/documents-api/spec.md @@ -9,7 +9,8 @@ ### Requirement: Файлы организации и карточка файла `GET /api/v1/legal-entities/{id}/files` (`DOCUMENT_VIEW`, policy; `include_superseded=false` по умолчанию) SHALL возвращать `Page` файлов ЮЛ по убыванию `uploaded_at`: поля `stored_file`, текущее задание (`status`, `error_code`, -`error_message`, `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`, +`error_message`, `mapping_error` (`metrics.mapping_error`: причина `ERROR` файла после неудачного маппинга, когда у задания +`error_code` пуст), `metrics.warnings`, `metrics.mapping_warnings`, `finished_at`), документы файла (`id`, `document_type`, `osv_account_code`, `period_year`, `period_quarter`, `is_cumulative`, `status`, `period_check`, `counterparty_check`). `GET /api/v1/files/{file_id}` SHALL отдавать ту же карточку; чужой или несуществующий файл — 404. @@ -30,7 +31,18 @@ ### Requirement: Подтверждение документа `POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED` или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`; -из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`. +из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом +другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Если совпали несколько условий, приоритет у +`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ» и перечитывает оба под `FOR UPDATE`; +документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`. + +#### Scenario: Причина ошибки маппинга в карточке файла +- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст) +- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст + +#### Scenario: Документ не подтверждается +- **WHEN** подтверждается документ в статусе `ERROR` +- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился #### Scenario: Подтверждение - **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 1b77029..68d8330 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -13,13 +13,14 @@ Проверка: `make dbml-check` пуст; `make migration-cycle`. - [x] 1.2 **S3 и фабрики.** `S3Storage.get_bytes(key) -> bytes` (+ закрытие response), `ObjectStorage`-протокол с `get_bytes`; `FakeStorage.get_bytes`; `tests/factories.py`: `make_stored_file(session, *, entity, status, content_hash, job_status)`, - `make_document`, `make_entries_from_result(session, file, result_json)`; `CommittedJobs.cleanup()` удаляет `osv_entry`, + `make_document`, `make_result_object`; `make_entries_from_result(session, file, job, result_json)` создан в Task 9 (R-2); + `CommittedJobs.cleanup()` удаляет `osv_entry`, `document`. Настройки `UPLOAD_MAX_BYTES` (50 МиБ), `MAPPING_POLL_SECONDS` (5), `MAPPING_MAX_ATTEMPTS` (3) + `.env.example`/`.env.test`. ## 2. Загрузка и статусы - [x] 2.1 **Загрузка файлов.** `domain/file_types.py` (расширение → `FileType`/`pipeline`, проверка сигнатуры), `services/primary_files.py` - (`upload_many`, дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`), + (`upload_one`, цикл по файлам — в эндпоинте; дедуп, S3, `stored_file`/`recognition_job`/`current_recognition_job_id`, аудит `FILE_UPLOAD`), `schemas/files.py` (`UploadResult{accepted,rejected}`, `FileOut`, `JobBrief`, `DocumentBrief`), эндпоинт `POST /legal-entities/{id}/files`, роутер. Тесты: сценарии спеки `primary-files` (принята, смесь, сигнатура, чужое ЮЛ, дубль); `fixtures`-тест: реальный xls принят и `publish_pending` на `FakeNats` публикует запрос с ключом `uploads/{file_id}/{job_id}/…`. @@ -31,8 +32,8 @@ ## 3. Маппинг - [x] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc, - chart, resolve)` (пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`; - `repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None). + chart)` без resolve (R-3: `EntryDraft.counterparty_id` проставляет цикл маппинга; пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `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`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа. - [x] 3.2 **Цикл маппинга.** `services/result_mapping.py`: `pick_candidate` (FOR UPDATE SKIP LOCKED), проверка ключа (OQ-70), чтение @@ -62,7 +63,7 @@ `services` (реальный `tabular-parser` против общего NATS/MinIO) — загрузка `rb/2024-FY/ОСВ_66 за 2024.xls` через API ядра, ожидание `PROCESSED` (≤ 60 с), проверка документа и 9 строк (10 строк снимка минус TOTAL); без профиля — skip с причиной. `make e2e` цель; `check-full` его включает (skip допустим). -- [ ] 5.2 **Документы.** README (загрузка: curl-пример, статусы файла и документа, цикл маппинга и настройки, API, удаление и замена, +- [x] 5.2 **Документы.** README (загрузка: curl-пример, статусы файла и документа, цикл маппинга и настройки, API, удаление и замена, ограничения), CLAUDE.md (DBML v5.15, `domain/osv_mapping.py`, `services/primary_files.py`, `services/result_mapping.py`, три фоновых цикла), `qa-guide` в docs — сценарии загрузки (в docs-change); `make check-full` зелёный; `tasks.md` `[x]`. - [ ] 5.3 После merge: `openspec archive slice-2c-osv-ingest`. -- 2.47.2 From 24d556e41ecfa5791b0e7d6e74323e446f2c4c3b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:20:07 +0400 Subject: [PATCH 29/35] =?UTF-8?q?test(audit):=20=D0=BE=D0=B1=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B8=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=B8=D1=88=D1=83=D1=82=20audit=5Fevent?= =?UTF-8?q?=20(OQ-55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Негативное требование спеки audit-trail: ack, результат, маппинг и таймаут вахтёра не создают строк audit_event. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/test_result_mapping.py | 52 +++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 8905423..a678d3d 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -13,7 +13,7 @@ import json import time import uuid from collections.abc import Callable -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path from typing import Any @@ -23,6 +23,7 @@ from loguru import logger from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from src.contract import ack_msg_id, result_msg_id from src.core.config import Settings, get_settings from src.models.access import AppUser from src.models.accounting import ChartOfAccount @@ -43,6 +44,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry from src.services import result_mapping as rm +from src.services.recognition_exchange import expire_deadlines, handle_message from src.services.result_mapping import ( MAPPING_ATTEMPTS_KEY, MAPPING_ERROR_KEY, @@ -56,6 +58,7 @@ from src.services.result_mapping import ( ) from tests.conftest import FakeStorage from tests.factories import make_document, make_legal_entity, make_result_object, make_stored_file +from tests.test_recognition_inbox import envelope, fake_msg API_DIR = Path(__file__).resolve().parents[1] MINIMAL_RESULT = API_DIR / "contracts" / "examples" / "result-osv-minimal.json" @@ -1020,3 +1023,50 @@ async def test_new_file_supersedes_several_files(committed_sessionmaker: async_s for event in events: assert event.user_id is None assert event.after_value == {"file_id": str(new_id), "version_no": expected_version} + + +# --- audit-trail: обмен и маппинг не пишут audit_event (ASSUMPTION(OQ-55)) ---------------------------------------- + + +async def _audit_count(maker: async_sessionmaker[AsyncSession]) -> int: + async with maker() as session: + return (await session.execute(select(func.count()).select_from(AuditEvent))).scalar_one() + + +async def test_exchange_and_mapping_write_no_audit_events(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """spec audit-trail «События файлов и документов»: смена статусов задания и файла (ack, результат, маппинг) и + автозамена без заменяемых файлов `audit_event` не пишут: путь `UPLOADED → PROCESSING → PROCESSED` оставляет таблицу + аудита неизменной.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare( + maker, storage, _minimal(), job_status=RecognitionStatus.published, file_status=FileStatus.UPLOADED + ) + before = await _audit_count(maker) + + await handle_message( + fake_msg(envelope(job_id, file_id, "processing"), ack_msg_id(job_id, 1)), session_factory=maker + ) + assert (await _file(maker, file_id)).status is FileStatus.PROCESSING + await handle_message(fake_msg(envelope(job_id, file_id, "done"), result_msg_id(job_id, 1)), session_factory=maker) + assert (await _job(maker, job_id)).status is RecognitionStatus.done + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + assert await _audit_count(maker) == before + + +async def test_watchdog_timeout_writes_no_audit_event(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Путь ошибки: вахтёр переводит задание в `timeout`, файл — в `ERROR`, `audit_event` не пишется.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal(), job_status=RecognitionStatus.processing) + async with maker() as session, session.begin(): + job = await session.get(RecognitionJob, job_id) + assert job is not None + job.reply_deadline = _now() - timedelta(minutes=1) + before = await _audit_count(maker) + + assert await expire_deadlines(maker, now=_now) >= 1 + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + assert (await _job(maker, job_id)).status is RecognitionStatus.timeout + assert await _audit_count(maker) == before -- 2.47.2 From 583edf2a8dc875b8ff2307cd9c1f6e5c1a510414 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:33:49 +0400 Subject: [PATCH 30/35] =?UTF-8?q?fix(mapping):=20=D0=BA=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20map?= =?UTF-8?q?ping=5Ferror,=20=D0=B2=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=BF=D0=BE=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-1 (= T7.6): metrics.mapping_error виден пользователю (JobBrief.mapping_error) — ошибка схемы даёт «результат не соответствует схеме recognition-result: ошибок N, первая: » без input_value и ссылок pydantic; исчерпавшая попытки ошибка БД/S3 — «маппинг не удался: ошибка БД/S3 (), попыток N» без [SQL: …] и параметров. Полный текст исключения — только в логе. T7.2: черновики osv_entry сортируются устойчиво по level перед вставкой пачками — родитель раньше потомка на любой границе пачки, независимо от порядка строк результата. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/result_mapping.py | 31 +++++++++++--- api/tests/test_result_mapping.py | 69 +++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 21439c0..dcb1010 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -17,6 +17,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). +`mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число +попыток), без текста исключений pydantic/SQLAlchemy; полный текст — только в логе (M-1). Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`). """ @@ -175,6 +177,16 @@ def validate_result_key(candidate: Candidate, storage_bucket: str) -> None: # --- шаг 2: чтение результата --------------------------------------------------------------------------------------- +def _schema_error_summary(exc: ValidationError) -> str: + """M-1: для `metrics.mapping_error` (видно пользователю) — число ошибок, путь и тип первой; без `input_value` и + ссылок pydantic (полный текст — в логе через `__cause__` у `MappingFatal`).""" + errors = exc.errors(include_url=False, include_input=False, include_context=False) + first = errors[0] if errors else None + where = ".".join(map(str, first["loc"])) if first and first["loc"] else "<корень>" + kind = first["type"] if first else "?" + return f"результат не соответствует схеме recognition-result: ошибок {exc.error_count()}, первая: {where} {kind}" + + def _osv_documents(result: RecognitionResult) -> list[OsvDocument]: return [document for document in result.documents if isinstance(document, OsvDocument)] @@ -188,7 +200,7 @@ async def _load_result(storage: ObjectStorage, candidate: Candidate) -> Recognit try: result = RecognitionResult.model_validate_json(content) except ValidationError as exc: - raise MappingFatal(f"файл результата не соответствует схеме recognition-result: {exc}") from exc + raise MappingFatal(_schema_error_summary(exc)) from exc if result.job_id != candidate.job_id or result.file_id != candidate.file_id: raise MappingFatal( f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" @@ -405,6 +417,9 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай session.add_all(documents) await session.flush() + # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк + # результата; сортировка устойчивая, внутри уровня порядок строк сохраняется + entries.sort(key=lambda values: values["level"]) for start in range(0, len(entries), ENTRY_BATCH_SIZE): await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) @@ -558,15 +573,18 @@ async def _lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | return (await session.execute(stmt)).scalar_one_or_none() -async def _fail(session: AsyncSession, candidate: Candidate, message: str, now: datetime) -> None: - """Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего).""" +async def _fail( + session: AsyncSession, candidate: Candidate, message: str, now: datetime, *, cause: BaseException | None = None +) -> None: + """Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего). + `cause` — исходное исключение (например, `ValidationError`): его полный текст пишется только в лог (M-1).""" job = await _lock_job(session, candidate.job_id) if job is None: return job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)} await session.flush() changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now) - logger.error( + logger.opt(exception=cause).error( "Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, " "file_id={file_id}, file_changed={changed}, error={error}", job_id=candidate.job_id, @@ -595,8 +613,9 @@ async def _register_failure( metrics[MAPPING_ATTEMPTS_KEY] = attempts exhausted = attempts >= max_attempts if exhausted: + # M-1: только категория и тип исключения — текст SQLAlchemy/asyncpg ([SQL: …], параметры) остаётся в логе metrics[MAPPING_ERROR_KEY] = _short( - f"маппинг не удался после {attempts} попыток: {type(exc).__name__}: {exc}" + f"маппинг не удался: ошибка БД/S3 ({type(exc).__name__}), попыток {attempts}" ) job.metrics = metrics await session.flush() @@ -674,7 +693,7 @@ async def process_once( await _map_candidate(session_factory, candidate, result, clock) except MappingFatal as exc: async with session_factory() as session, session.begin(): - await _fail(session, candidate, str(exc), clock()) + await _fail(session, candidate, str(exc), clock(), cause=exc.__cause__) except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор после паузы цикла (R-15) return await _register_failure( session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock() diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index a678d3d..8cf3882 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -21,6 +21,7 @@ from typing import Any import pytest from loguru import logger from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract import ack_msg_id, result_msg_id @@ -378,12 +379,78 @@ async def test_mapping_failure_retries_then_error( assert await process_once(maker, storage, _settings()) is True job = await _job(maker, job_id) assert job.metrics[MAPPING_ATTEMPTS_KEY] == 3 # noqa: PLR2004 - MAPPING_MAX_ATTEMPTS по умолчанию - assert "подставная ошибка БД" in job.metrics[MAPPING_ERROR_KEY] + # M-1: пользователю — категория и число попыток, текст исключения остаётся только в логе + assert job.metrics[MAPPING_ERROR_KEY] == "маппинг не удался: ошибка БД/S3 (RuntimeError), попыток 3" assert (await _file(maker, file_id)).status is FileStatus.ERROR assert len(calls) == 3 # noqa: PLR2004 - три попытки assert await process_once(maker, storage, _settings()) is False +_INTERNALS = ("[SQL:", "input_value", "http", "sqlalche.me", "parameters") + + +async def test_schema_error_hides_input_values(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """M-1: ошибка схемы результата — в `mapping_error` число ошибок и путь/тип первой, без `input_value` и ссылок + pydantic (полный текст — только в логе).""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() + _osv(result)["rows"][1]["opening_debit"] = "секрет http://example.invalid" + file_id, job_id, _ = await _prepare(maker, storage, result) + + assert await process_once(maker, storage, _settings()) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + message = (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + assert message.startswith("результат не соответствует схеме recognition-result: ошибок ") + assert "первая: documents.0." in message + assert "opening_debit" in message + assert "секрет" not in message + assert not [marker for marker in _INTERNALS if marker in message] + + +async def test_retryable_db_error_hides_sql( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """M-1: исчерпавшая попытки ошибка БД — в `mapping_error` категория, тип исключения и число попыток, без + `[SQL: …]`, параметров и ссылок SQLAlchemy.""" + maker, storage = committed_sessionmaker, FakeStorage() + file_id, job_id, _ = await _prepare(maker, storage, _minimal()) + + async def failing_map_file(session: AsyncSession, **kwargs: Any) -> list[str]: + raise IntegrityError("INSERT INTO osv_entry (id) VALUES ($1)", {"id": "секрет"}, Exception("fk violation")) + + monkeypatch.setattr(rm, "map_file", failing_map_file) + settings = _settings().model_copy(update={"MAPPING_MAX_ATTEMPTS": 1}) + + assert await process_once(maker, storage, settings) is True + + assert (await _file(maker, file_id)).status is FileStatus.ERROR + message = (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + assert message == "маппинг не удался: ошибка БД/S3 (IntegrityError), попыток 1" + assert not [marker for marker in _INTERNALS if marker in message] + + +async def test_child_before_parent_across_batches( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +): + """T7.2: строки вставляются родитель раньше потомка независимо от порядка строк в результате — потомок перед + родителем на границе пачки не нарушает self-FK `parent_entry_id`.""" + maker, storage = committed_sessionmaker, FakeStorage() + result = _minimal() + rows = _osv(result)["rows"] + rows.reverse() # потомок (level 1) раньше корня (level 0) + monkeypatch.setattr(rm, "ENTRY_BATCH_SIZE", 1) # каждая строка — отдельная пачка + file_id, _, _ = await _prepare(maker, storage, result) + + 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) + entries = await _entries(maker, document.id) + root, child = entries + assert child.parent_entry_id == root.id + + async def test_repeated_mapping_is_idempotent(committed_sessionmaker: async_sessionmaker[AsyncSession]): """«Повторный маппинг идемпотентен»: остатки прерванной вставки (документ и половина строк) удаляются, у файла ровно один набор документов и строк.""" -- 2.47.2 From 2ac19c6953804b53db7f7ec3cbba0d6e3920c6ce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:35:43 +0400 Subject: [PATCH 31/35] =?UTF-8?q?fix(files):=20DELETE=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=20expires=5Fat=20=D1=82=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BE=D0=BA=20=D1=81=D0=B2=D0=BE=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-3: s3_object.expires_at = now ставится только объектам с ролью uploads и ключом uploads/{file_id}/… или с ролью results и ключом results/{file_id}/…; ссылка задания на объект другого файла (inbox переиспользует s3_object при конфликте (bucket, key)) не трогается и ошибкой не считается. Фабрика make_stored_file кладёт загрузку в папку файла uploads/{file_id}/…, как upload_key в проде. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/files.py | 27 +++++++++++++++++++++------ api/src/services/primary_files.py | 11 +++++++++-- api/tests/factories.py | 4 +++- api/tests/test_api_files_delete.py | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index aa538f2..eaf81a1 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -12,12 +12,12 @@ import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, delete, func, select, update +from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.directories import LegalEntity -from src.models.enums import FileStatus +from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -174,7 +174,22 @@ async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: await session.execute(delete(StoredFile).where(StoredFile.id == file_id)) -async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None: - """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" - if object_ids: - await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) +async def expire_s3_objects( + session: AsyncSession, + object_ids: Sequence[uuid.UUID], + *, + owned_prefixes: Sequence[tuple[S3BucketRole, str]], + now: datetime.datetime, +) -> None: + """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22) — только + объектам из `object_ids`, чья пара (роль бакета, начало ключа) входит в `owned_prefixes`. Остальные (например, + ссылка задания на объект другого файла) не трогаются и ошибкой не считаются (M-3).""" + if not object_ids or not owned_prefixes: + return + owned = or_( + *( + and_(S3Object.bucket_role == role, S3Object.key.startswith(prefix, autoescape=True)) + for role, prefix in owned_prefixes + ) + ) + await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 3ede2d3..4ed1bcd 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -50,6 +50,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND +from src.services.result_mapping import RESULTS_PREFIX OBJECT_TYPE = "stored_file" UPLOADS_PREFIX = "uploads" @@ -174,7 +175,8 @@ class PrimaryFilesService: исчезнувший, пока ждали блокировку, — 404. Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий - остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает + остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и + `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает ретеншн бакета. `before_value` аудита — карточка файла.""" visible = await files_repo.get_file(self._session, self._scope, file_id) if visible is None: @@ -197,7 +199,12 @@ class PrimaryFilesService: before = file_out(file, current, documents).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] await files_repo.delete_file_rows(self._session, file) - await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now()) + # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла + owned_prefixes = [ + (S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"), + (S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"), + ] + await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now()) await self._audit.record( action=AuditAction.FILE_DELETE, section=AuditSection.PRIMARY_DOCUMENTS, diff --git a/api/tests/factories.py b/api/tests/factories.py index f8b5007..dfa1cae 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики сессии (`flush`). """ now = _now() + file_id = uuid.uuid4() s3 = S3Object( bucket_role=S3BucketRole.uploads, bucket=FACTORY_BUCKET, - key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}", + key=s3_key or f"uploads/{file_id}/{uuid.uuid4()}/{original_name}", # как `upload_key`: папка файла created_at=now, ) session.add(s3) await session.flush() file = StoredFile( + id=file_id, legal_entity_id=entity.id, file_type=file_type, original_name=original_name, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 5d39bdb..b45e4bf 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -102,6 +102,28 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.after_value is None +async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка + задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + other_file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, original_name="other.xls") + job.result_s3_object_id = other_file.s3_object_id # результат «указывает» на загрузку другого файла + await seeded.flush() + file_id, own_id, foreign_id = file.id, file.s3_object_id, other_file.s3_object_id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + own = await seeded.get(S3Object, own_id, populate_existing=True) + foreign = await seeded.get(S3Object, foreign_id, populate_existing=True) + assert own is not None and own.expires_at is not None + assert foreign is not None and foreign.expires_at is None + + async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" entity = await _rostov_entity(seeded) -- 2.47.2 From d14295b6d05286aadb3ca7e8c828f33c1c67b1c7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:38:02 +0400 Subject: [PATCH 32/35] =?UTF-8?q?docs:=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=A1=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=202c,=20OQ-117,=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=84=9651=E2=80=9352,=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=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`, резолв (ИНН/имя/алиас/неоднозначно), проверка шапки (ИНН, имя, алиас, пусто), статус документа. -- 2.47.2 From 75feab6015726e51cacff28624c3c51788342060 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:39:40 +0400 Subject: [PATCH 33/35] =?UTF-8?q?test:=20=D0=B3=D0=B8=D0=B3=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=A1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=202c=20=D0=B8=20=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20email?= =?UTF-8?q?=20seed=20=D0=B2=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=BC=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_api_documents: тавтология по counterparty_name строки 18 ОСВ_66 заменена значениями снимка (T9.3). - test_mapping_golden: план счетов не пуст и есть строки с account_id (T8.6). - e2e: код ответа DELETE проверяется после try/finally — не маскирует причину падения (T11.2). - test_counterparty_resolve: единственное точное имя побеждает неоднозначность нормализованного (T6.1). - test_domain_osv_mapping: шапка «ООО» без ИНН → UNKNOWN (T5.1). - seed_lib/apply: email пользователя нормализуется один раз для upsert и ролей, поведение не меняется (T1.1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/scripts/seed_lib/apply.py | 8 ++++---- api/tests/e2e/test_osv_ingest_e2e.py | 5 +++-- api/tests/test_api_documents.py | 3 ++- api/tests/test_counterparty_resolve.py | 12 ++++++++++++ api/tests/test_domain_osv_mapping.py | 1 + api/tests/test_mapping_golden.py | 5 ++++- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 3ca02bb..975f78d 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -192,8 +192,8 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt ).all() } user_ids: dict[str, Any] = {} - for user in data.users: - email = user.email.strip().lower() + users = [(user.email.strip().lower(), user) for user in data.users] # email нормализуется один раз + for email, user in users: values = { "full_name": user.full_name, "employee_no": user.employee_no, @@ -219,9 +219,9 @@ async def _apply_users(session: AsyncSession, data: SeedData, user_stat: TableSt user_stat.unchanged += 1 existing = set((await session.execute(select(UserRole.user_id, UserRole.role_id))).all()) new_rows = [] - for user in data.users: + for email, user in users: for role_code in user.role_codes: - pair = (user_ids[user.email.strip().lower()], role_ids[role_code]) + pair = (user_ids[email], role_ids[role_code]) if pair in existing: link_stat.unchanged += 1 else: diff --git a/api/tests/e2e/test_osv_ingest_e2e.py b/api/tests/e2e/test_osv_ingest_e2e.py index 500a28f..170df42 100644 --- a/api/tests/e2e/test_osv_ingest_e2e.py +++ b/api/tests/e2e/test_osv_ingest_e2e.py @@ -157,7 +157,8 @@ async def test_real_osv_is_parsed_end_to_end( root = entries["items"][0] assert (root["account_code"], root["level"]) == ("66", 0) finally: - # очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой + # очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой. Код ответа + # проверяется после блока: assert в finally заменил бы исключение проверки выше и скрыл причину падения deleted = await client.delete(f"/files/{file_id}") - assert deleted.status_code == HTTP_NO_CONTENT, deleted.text + assert deleted.status_code == HTTP_NO_CONTENT, deleted.text assert (await client.get(f"/files/{file_id}")).status_code == HTTP_NOT_FOUND diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index 74d7869..c56f5fe 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -192,7 +192,8 @@ async def test_entries_as_tree_on_golden_osv_66( assert row["group_type"] == "COUNTERPARTY" yug = next(row for row in rows if row["row_number"] == 18) # noqa: PLR2004 assert yug["account_id"] is not None - assert yug["counterparty_name"] is None or isinstance(yug["counterparty_name"], str) + # значения снимка ОСВ_66 за 2024, строка 18: контрагент без ИНН в колонке + assert (yug["counterparty_name"], yug["counterparty_inn"]) == ("Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ", None) assert set(first) == { "id", "row_number", diff --git a/api/tests/test_counterparty_resolve.py b/api/tests/test_counterparty_resolve.py index 1153dd0..b0b0a6e 100644 --- a/api/tests/test_counterparty_resolve.py +++ b/api/tests/test_counterparty_resolve.py @@ -69,6 +69,18 @@ async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None assert index.resolve(None, "ООО Дубль Имени") is None +async def test_unique_exact_name_wins_over_ambiguous_normalized(db_session: AsyncSession) -> None: + """Приоритет шагов: единственное точное имя побеждает неоднозначность нормализованного (оба ЮЛ нормализуются в + одно имя, но точное совпадение у одного).""" + ooo = await make_legal_entity(db_session, name="ООО Альфа Приоритет") + await make_legal_entity(db_session, name="АО Альфа Приоритет") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve(None, "ООО Альфа Приоритет") == ooo.id + # без точного совпадения нормализованное имя неоднозначно → None + assert index.resolve(None, "Альфа Приоритет") is None + + async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSession) -> None: entity = await make_legal_entity(db_session, name="ООО «Нормализуемое Имя»") index = await CounterpartyIndex.load(db_session) diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 48d79c4..05c6cb9 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -252,6 +252,7 @@ def test_document_fields_on_golden_osv_66(fixtures_dir: Path) -> None: ({"organization_name": "Пекарня Счастливочная"}, DocumentCheckResult.MISMATCH), ({"organization_name": "", "organization_inn": None}, DocumentCheckResult.UNKNOWN), # пустая шапка ({"organization_name": " ", "organization_inn": ""}, DocumentCheckResult.UNKNOWN), + ({"organization_name": "ООО"}, DocumentCheckResult.UNKNOWN), # только ОПФ: нормализуется в пустое имя ], ) def test_counterparty_check(header: dict[str, Any], expected: DocumentCheckResult) -> None: diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index de49dfb..8b95f07 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -101,6 +101,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P chart = { code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) } + assert chart, "план счетов seed пуст — сверка account_id ничего не проверит" index = await CounterpartyIndex.load(session) mapped: list[tuple[Path, uuid.UUID, RecognitionResult]] = [] @@ -121,7 +122,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P problems: list[str] = [] missing_codes: set[str] = set() - documents_total = entries_total = 0 + documents_total = entries_total = with_account = 0 for path, file_id, _ in mapped: name = str(path.relative_to(fixtures_dir / GOLDEN_DIR)) raw = json.loads(path.read_text("utf-8")) @@ -148,6 +149,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P missing_codes |= sheet_missing documents_total += 1 entries_total += len(entries) + with_account += sum(1 for entry in entries if entry.account_id is not None) file_status = (await session.execute(select(StoredFile.status).where(StoredFile.id == file_id))).scalar_one() if file_status not in {FileStatus.PROCESSED, FileStatus.SUPERSEDED}: problems.append(f"{name}: статус файла {file_status}") @@ -158,4 +160,5 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P f"{sorted(missing_codes)}" ) assert problems == [] + assert with_account > 0 # строки со счётом плана счетов есть — сверка account_id не вырождена assert mapping_seconds <= TIME_BUDGET_SECONDS -- 2.47.2 From b054ca6261f98e4aad59ab160241920be8176076 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:50:17 +0400 Subject: [PATCH 34/35] =?UTF-8?q?docs:=20ledger=20SDD=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=202c=20(=D1=80=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8?= =?UTF-8?q?,=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BA=D0=B8,=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../2026-10-05-slice-2c-osv-ingest-ledger.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest-ledger.md diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest-ledger.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest-ledger.md new file mode 100644 index 0000000..4858cec --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest-ledger.md @@ -0,0 +1,153 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md + +Spec: openspec/changes/slice-2c-osv-ingest/ (доступна). Merge-base ветки: 55e538c (HEAD плана). Docs worktree: ../docs (ветка feat/16-slice-2c-osv-ingest). +Маркеры: T. minor задачи, C/I/M финальное ревью, R-n рулинг (design #m), O-n наблюдение. + +## Pre-flight: таблица конфликтов (пары задач по общим файлам/интерфейсам) + +| Пара | Что производит -> потребляет | Найдено | +|---|---|---| +| T1 -> T2..T12 | T1: enums.NU, DBML_VERSION 5.15, Makefile DBML, HEAD миграций, email lower | чисто | +| T2 -> T3,T4,T6,T7,T9,T10 | T2: ObjectStorage/ObjectNotFoundError, FakeStorage.get_bytes, make_stored_file/make_document/make_result_object, настройки | tasks.md 1.2 ждёт ещё `make_entries_from_result`; в плане T2 его нет, а T9 использует -> R-2 | +| T3 -> T9,T10 | T3: PrimaryFilesService, schemas/files.py (FileOut/JobBrief/DocumentBrief), dependencies/files.py, files router, common/filenames | T9 расширяет `schemas`/`dependencies` (OK); T10 добавляет `delete` в тот же сервис (OK) | +| T3 -> T2 | T3 переносит safe_filename/CONTENT_TYPES в common/filenames.py; T2 переносит ObjectStorage из directory_import.py | оба правят directory_import.py; T2 раньше T3 - порядок задаёт отсутствие конфликта | +| T4 -> T7 | T4: set_file_status_for_job, PROCESSING по ack; T7 pick_candidate требует file.status=PROCESSING | ack-less результат (ack потерян, core NATS без JetStream) оставит файл UPLOADED навсегда -> R-4; helper не различает UPLOADED/ERROR при ack -> R-5 | +| T4 -> T7 | T4 удаляет ResultMapper/handle_message(mapper); T7 меняет ExchangeTasks.start/app.py | оба правят recognition_exchange.py и app.py: последовательно, T4 раньше (OK) | +| T5 -> T6 | normalize_alias из domain/aliases.py; T6 использует его же | чисто | +| T5 -> T7 | T5: EntryDraft, build_entries(doc, chart) без resolve; T7 нужен counterparty_id | EntryDraft без `counterparty_id`, design D-6 ожидал `resolve` -> R-3 | +| T6 -> T7 | CounterpartyIndex.load/resolve/owner_names | чисто | +| T7 -> T8 | map_file; T8 вызывает supersede_previous после PROCESSED в той же транзакции | T7 говорит "завершается PROCESSED", T8 добавляет вызов: OK. uploader_id берётся из file.uploaded_by | +| T7/T8 -> tasks.md 3.4 | golden-прогон 82 снимков (spec osv-result-mapping, сценарий) | в плане нет задачи -> R-1 | +| T8 -> T9 | заменённые файлы скрыты include_superseded (проверка в T9) | чисто | +| T9 -> T10 | repositories/files.py get_file; T10 добавляет repo-функции; DELETE маршрут в read.py | чисто | +| T9 -> T7 | DocumentOut.warnings / group_schema: источник job.metrics.mapping_warnings и job.group_schema (T7) | неоднозначно привязка предупреждений к документу -> R-6 | +| T10 -> T11 | e2e использует DELETE для очистки | чисто | +| T11 -> T12 | Makefile check-full включает e2e; README | T12 тоже правит README: последовательно (OK) | +| T12 -> T1 | docs-коммиты в ../docs: T1 (entities-v5.15, guide, шаблоны) и T12 (§1.3.. qa-guide) | разные файлы, кроме tasks.md docs-change (1.2 в T1, 2.1 в T12) - OK | + +Самосогласованность каждой задачи: +| Task | Своё содержимое | Находка | +|---|---|---| +| 1 | тесты <-> код (NU, 0003, email) согласованы; docs-часть - файлы в другом worktree + workspace CLAUDE.md вне git | R-7 | +| 2 | Files перечислены; тесты test_clients/test_factories | чисто | +| 3 | интерфейс vs тесты: 413 -> RejectedFile; 422 если accepted пуст; fixtures-маркер на реальном xls | чисто | +| 4 | helper WHERE NOT IN (SUPERSEDED, PROCESSED); ack "только из UPLOADED" в тексте, но helper этого не обеспечивает | R-5 | +| 5 | DocumentDraft упомянут без определения | определяет исполнитель (dataclass в osv_mapping.py) - не рулинг | +| 6 | чисто | чисто | +| 7 | map_file в тексте "завершается PROCESSED"; metrics jsonb мутировать через новый dict/flag_modified | заметка исполнителю | +| 8 | чисто (тест include_superseded - в T9) | чисто | +| 9 | make_entries_from_result используется, но не создан | R-2 | +| 10 | порядок: current_recognition_job_id=NULL + flush до удаления job | чисто | +| 11 | skip без профиля; e2e Makefile | чисто | +| 12 | `openspec validate --strict` в обоих репо | чисто | + +## Rulings + +R-1 (design #14): Ruling: golden-прогон (tasks 3.4, 82 снимка) не вынесен в задачу плана - выполняется как второй коммит Task 8 (после появления полного map_file с автозаменой): `api/tests/test_mapping_golden.py`, маркеры integration+fixtures, инварианты спеки, <= 60 с - спека требует сценарий "Все golden-снимки"; цена ошибки: тест переезжает в другой коммит. +R-2 (design #15): Ruling: `make_entries_from_result(session, file, job, result_json)` в tests/factories.py создаётся в Task 9 (зависит от build_entries из Task 5), а не в Task 2 - иначе Task 2 тянет домен, которого ещё нет; цена: ничего. +R-3 (design #16): Ruling: `EntryDraft` получает поле `counterparty_id: uuid.UUID | None = None` (Task 5); `build_entries` остаётся чистой функцией без resolve (как в плане); Task 7 проставляет его через `dataclasses.replace(draft, counterparty_id=index.resolve(...))` только для group_type=COUNTERPARTY и data_type in {BU, SUM} - сохраняет сигнатуру плана, закрывает требование спеки "Резолв контрагента"; цена: сигнатуру можно переделать на callable (D-6). +R-4 (design #17): Ruling: inbox при результате `done`/`needs_check` переводит файл UPLOADED -> PROCESSING (только текущее задание, только из UPLOADED) - core NATS без JetStream, ack может потеряться; без этого файл с готовым результатом никогда не попадёт в pick_candidate (требует PROCESSING); не противоречит спеке ("файл остаётся PROCESSING" выполняется); цена: убрать одну ветку. +R-5 (design #18): Ruling: `set_file_status_for_job(..., only_from: Collection[FileStatus] | None = None)`: ack -> PROCESSING только `only_from={UPLOADED}` (ack после ERROR/timeout не воскрешает файл); R-4 то же; ERROR/SUPERSEDED/PROCESSED - как в плане; цена: параметр. +R-6 (design #19): Ruling: `DocumentOut.warnings` = `job.metrics.mapping_warnings` файла целиком (файл-уровневые, не по листам; у большинства файлов один документ), `group_schema` - элемент `job.group_schema` с `locator` документа; цена: фильтрация по листу позже. +R-7 (design #20): Ruling: Task 1 правит workspace `/Users/andrey.epifancev/projects/awp-ca/CLAUDE.md` (вне git, по плану) - только замена упоминаний версии DBML v5.14 -> v5.15; цена: откат правки текста. + +## Progress +Task 1: dispatched (sonnet, BASE d905e3a, agent a609035fa8271560d) +Task 1: implementer DONE (arm-core ab606dc, docs 5545ee5); решение исполнителя: test_api_constraint_races email-race тест инвертирован (UNIQUE теперь ловит гонку) — следствие спеки; review dispatched (sonnet) +Task 1: minor T1.1 (deferred): seed_lib/apply.py ~232 дубль нормализации email в цикле ролей (один map) +Task 1: complete (commits d905e3a..ab606dc + docs 5545ee5, review clean) +Task 2: dispatched (sonnet, BASE ab606dc, agent aabc48c2714385737) +Task 2: implementer DONE 2d69a9e; review dispatched +Task 2: minor T2.1 (deferred -> Task 12): tasks.md 1.2 текст упоминает make_entries_from_result (R-2 -> Task 9): поправить формулировку в Task 12 +Task 2: minor T2.2 (deferred -> Task 9): test_make_document_defaults_and_uniqueness не проверяет уникальность — переименовать (Task 9 правит factories/test_factories) +Task 2: minor T2.3 (deferred): TABULAR_FILE_TYPES в factories дублирует DEFAULT_FILE_TYPES; T2.4 (rejected: D-12 vs бриф — response.read() эквивалентно); T2.5 (note): make_stored_file job.max_attempts = DB default 1 — тесты Task 3+ передают явно +Task 2: complete (commits ab606dc..2d69a9e, review clean) +Task 3: dispatched (opus, BASE 2d69a9e, agent aadbd466c7da63383) +Task 3: implementer DONE be0d1b4; review dispatched (opus) +R-8 (design #21): Ruling: 503 STORAGE_UNAVAILABLE через DomainError, 413 строкой только внутри read_limited — Global Constraints/спека главнее брифа — цена: формат тела 503. +R-9 (design #22): Ruling: сбой S3 на файле → rejected STORAGE_UNAVAILABLE по файлу (все отклонены → 422) — цена: вернуть 503. +R-10 (design #23): Ruling: SAVEPOINT на файл внутри транзакции запроса (спека: "отдельная транзакция") — бриф плана; цена: сессия на файл. +R-11 (design #24): Ruling: advisory lock (ЮЛ,sha256), requested_at, size_bytes, .jpeg, operation_id — подтверждены тестами; цена: убрать блокировку. +Task 3: minor T3.1 (deferred): _lock_content docstring неточен про ROLLBACK TO SAVEPOINT; T3.2 (deferred): теоретический deadlock advisory lock при встречном порядке файлов — комментарий; T3.3 (deferred): except Exception вокруг put_object маскирует баги под STORAGE_UNAVAILABLE; T3.4 (deferred): responses={422: UploadResult} затирает HTTPValidationError в OpenAPI; T3.5 (deferred): src.domain.file_types зависит от FastAPI через common/filenames (разделить); T3.6 (deferred): нет тестов на filename="" и два одинаковых файла в одном запросе; T3.7 (fix в Task 12): tasks.md 2.1 upload_many -> upload_one; T3.8 (rejected: по брифу): xls сигнатура 4 байта OLE2 +Task 3: complete (commits 2d69a9e..be0d1b4, review clean) +Task 4: dispatched (opus, BASE 8d2ced8, agent a4fa10feda2880cdd) +Task 4: implementer DONE b7ca728 (343 integration passed); review dispatched (opus). Lock order job->file обязателен для Task 7 (carry in dispatch) +Task 4: review: Important T4.1 (INVALID_REQUEST публикатора не ставит файлу ERROR) -> fix round 1 (resume implementer) +Task 4: minor T4.2 (deferred): нет индекса stored_file.current_recognition_job_id, пакетный UPDATE вахтёра — seq scan (взять RETURNING id,file_id + id IN); T4.3 (deferred): повторный ERROR из ERROR сдвигает updated_at; T4.4 (deferred): dlq не проверяет rowcount UPDATE задания; T4.5 (deferred): тест отката не проверяет откат s3_object; T4.6 (deferred): docstring recognition_exchange.py:42 ссылается на ещё не существующий result_mapping.py (появится в Task 7); T4.7 (carry): порядок блокировок job -> file обязателен для Task 7 и Task 10 +Task 4: T4.1 fixed 3b4d3a4; fix round 1/5 (1 addressed, 0 open; commits b7ca728..3b4d3a4); контроллер: полный `pytest -m integration` после Task 4 — 344 passed (регрессия обмена Среза 0 зелёная) +Task 4: complete (commits 8d2ced8..3b4d3a4, review clean) +Task 5: dispatched (opus, BASE 3b4d3a4, agent abeead383539fda93) +O-1: план/бриф Task 5/7/11 говорят «20 строк» у golden ОСВ_66 за 2024; фактически в снимке 10 строк (9 без TOTAL); критерий — «строк результата − 1» (спека). Task 7/11 использовать это правило, не число 20. +Task 5: implementer DONE f9306b8 (41 unit + 2 fixtures passed); review dispatched (opus) +R-12 (design #25): Ruling: корень листа = первая строка level 0 без родителя; шапка с пустым нормализованным именем без ИНН -> UNKNOWN; ИНН по цифрам; ±0.01 включительно — решения исполнителя Task 5 приняты; цена: MISMATCH вместо UNKNOWN. +R-13 (design #26): Ruling: «20 строк» в плане/tasks.md исправлено на 9 — в снимке 10 строк минус TOTAL; цена: нет. +Task 5: minor T5.1 (deferred): нет теста «ООО» -> UNKNOWN; T5.2 (deferred): ИНН вида «ИНН/КПП …» склеится в 19 цифр -> MISMATCH (в данных нет); T5.3 (deferred): дубль row_number схлопывает id (контракт гарантирует); T5.4 (note): третий сегмент двузначный не найдёт план — по букве спеки +Carry: Task 7 — ветка «нет корня» в D-9 (пустые листы root=None); Task 8 golden — account_id сверять с планом счетов seed (19 нормализованных кодов golden нет в seed -> NULL легально), а не «заполнено у всех» +Task 5: complete (commits 3b4d3a4..f9306b8, review clean) +Task 6: dispatched (sonnet, BASE 4e92532, agent abc225b5d10445e26) +Task 6: implementer DONE 9658f80 (10 integration passed); review dispatched (sonnet) +R-14 (design #27): Ruling: ИНН точно после normalize_inn; пустая нормализация не в индексе; единственное точное совпадение имени побеждает неоднозначность нормализованного (по шагам) — цена: приоритет шагов. +Task 6: minor T6.1 (deferred): нет теста приоритета «точное имя побеждает дубль нормализованного» (добавить в Task 12 или финале); T6.2 (deferred): ИНН вида «3528052718.0»/с префиксом не матчится (fallback на имя); T6.3 (deferred): load кэшировать по циклу, если справочник вырастет; T6.4 (rejected): Self вместо кавычек — косметика; T6.5 (deferred): комментарий про переименование «Альфа»→«Альфаомега» в тесте +Task 6: complete (commits 4e92532..9658f80, review clean); tasks.md 3.1 отмечен +Task 7: dispatched (opus, BASE 63343d3, agent ab3ea8f9a676619bd) +Task 7: implementer DONE e704b02 (29 mapping tests; 387 integration|fixtures; perf ОСВ_60 0.294 с; golden 1034 строки vs 1045 в плане); review dispatched (opus) +R-15 (design #28): Ruling: после повторяемой неудачи маппинга process_once возвращает False -> пауза MAPPING_POLL_SECONDS — иначе 3 попытки за мс; цена: задержка в metrics. +R-16 (design #29): Ruling: статус документа по предупреждениям его листа; metrics.mapping_warnings — общий список файла — цена: передавать весь список. +R-17 (design #30): Ruling: «нарастающая по данным Q1» -> предупреждение -> NEEDS_CHECK (литерально D-7/D-9) — цена: исключить предупреждение из правила статуса. ДЛЯ ЧЕЛОВЕКА: любой Q2-Q4 документ, нарастающий определённый по данным, остаётся NEEDS_CHECK. +R-18 (design #31): Ruling: job_id/file_id в результате != заданию -> fatal; S3-ошибки кроме NotFound повторяемые; BALANCE/INCOME/OTHER игнорируются; сироты -> NEEDS_CHECK; ключ с ./../пустыми сегментами отвергается — цена: убрать проверки. +Task 7: review: Important T7.1 (повторы без паузы) -> fix round 1 (resume implementer; R-15) +Task 7: minor T7.2 (deferred): вставка пачками по 1000 с self-FK опирается на порядок parent раньше child (tabular-parser гарантирует, контракт нет) — сортировать по level или задокументировать; T7.3 (deferred): при снижении MAPPING_MAX_ATTEMPTS файлы с attempts>=max зависают в PROCESSING; T7.4 (deferred): два инстанса могут параллельно мапить одного кандидата (D-1; второй пропускает на шаге 3, S3 читается дважды, попытка при ошибке считается дважды); T7.5 (deferred): при старте без S3 цикл не запускается до рестарта; T7.6 (deferred, security-ish): текст pydantic ValidationError с input_value попадает в metrics.mapping_error (обрезать до типа и пути); T7.7 (carry -> Task 10): DELETE /files обязан держать порядок job -> file; T7.8 (carry -> Task 9): DocumentOut.warnings (R-6) может показывать предупреждения чужого листа — уточнить; T7.9 (deferred): golden-тест сверяет 4 из 6 сумм (closing_* нет); T7.10 (deferred): цепочечное сравнение yug.account_id == chart_6603 is not None нечитаемо; T7.11 (deferred): решения #3/#4/#8 без тестов; T7.12 (deferred): тесты validate_result_key под integration (не в make check); T7.13 (deferred): при documents: [] сообщение «схема», а не «нет документов ОСВ» (исход ERROR верен) +Task 7: T7.1 fixed 67e255d; fix round 1/5 (1 addressed, 0 open; commits e704b02..67e255d) +O-2: ревьюер запускал pytest через `uv run` и оставил нетрекаемый api/uv.lock — удалён контроллером; ревьюерам: использовать api/.venv/bin/pytest +Task 7: complete (commits 63343d3..67e255d, review clean) +Task 8: dispatched (opus, BASE 67e255d, agent a545c94b9bd55b984); включает R-1 golden вторым коммитом +Task 8: implementer DONE b62cec8 + cd4220e (golden: 82 снимка/85 документов/13510 строк, 4.07 с; 398 integration|fixtures passed); review dispatched (opus) +R-19 (design #32): Ruling: автозамена под advisory-lock по ЮЛ + FOR UPDATE старых файлов по id, документы перечитываются после блокировки; документ без года/квартала не участвует — цена: убрать блокировку. +Task 8: review: Important T8.1 (нет теста: один новый файл заменяет несколько разных старых; version_no=max+1) -> fix round 1 (resume implementer) +Task 8: minor T8.2 (deferred): advisory-lock без теста гонки; T8.3 (deferred): при смене ключа документа между выборкой и блокировкой description пустой «документы: ;»; T8.4 (deferred -> вопрос человеку/OQ): старый файл с документами 60 и 51, замещённый файлом только с 60, целиком SUPERSEDED — документ 51 пропадает из списка; в description про 51 нет; T8.5 (deferred): golden — level сверять с row["level"]; T8.6 (deferred): golden — assert непустого плана счетов (иначе при пустом seed тест ничего не проверит); T8.7 (deferred): golden — повтор счёта в двух листах даст ложное падение при новых фикстурах +O-3: 19 нормализованных кодов строк-счетов golden нет в seed плана счетов (01.01, 66.01, 76.01, 60.АВ, 76.ОТ…) -> account_id NULL; проверить полноту seed субсчетов до Среза ВГО +Task 8: T8.1 fixed f2324b1; fix round 1/5 (1 addressed, 0 open; commits cd4220e..f2324b1) +Task 8: complete (commits 67e255d..f2324b1, review clean); tasks.md 3.3, 3.4 отмечены +Task 9: dispatched (opus, BASE f2324b1, agent a6be160d81a7aee15) +Task 9: implementer DONE 20983fb (427 integration|fixtures passed; 11 решений); review dispatched (opus) +R-20 (design #33): Ruling: JobBrief.mapping_error; 409 DOCUMENT_NOT_CONFIRMABLE; FILE_SUPERSEDED раньше ALREADY_CONFIRMED; исчезнувший документ -> 404 — цена: убрать поле/код. -> дописать в documents-api spec delta (Task 12). +R-21 (design #34): Ruling: confirm: блокировки файл -> документ, перечитывание под FOR UPDATE; чтение файлов в PrimaryFilesService, документов в DocumentService; DocumentOut по текущему заданию; пачечная загрузка вместо relationship — цена: порядок блокировок. +R-22 (design #35): Ruling: test_openapi_routes отвергает лишние маршруты под /legal-entities,/files,/documents (Task 10 обязан добавить DELETE) — цена: ослабить тест. +Task 9: minor T9.1 (deferred): make_entries_from_result импортирует приватные _entry_values/_group_schema маппера — сделать публичными; T9.2 (deferred): make_entries_from_result не проставляет counterparty_id; T9.3 (deferred): тавтологичная проверка `yug["counterparty_name"] is None or isinstance(...)` в golden-тесте test_api_documents; T9.4 (deferred): FileListQuery уже PageParams — лишний второй PageParams в read.py; T9.5 (deferred): дубль констант VIEW/NOT_FOUND в read.py и documents.py; T9.6 (deferred): file_out живёт в services/documents.py, импортируется из primary_files; T9.7 (deferred, follow-up для 2d): EntryOut без is_modified (нужен панели правки); T9.8 (carry -> Task 12): README каталог кодов ошибок (Task 3 + Task 9) и spec delta documents-api (mapping_error, DOCUMENT_NOT_CONFIRMABLE) +Task 9: complete (commits f2324b1..20983fb, review clean); tasks.md 4.1 отмечен +Task 10: dispatched (sonnet, BASE e2fde90, agent aeaa61c38463579b6) +Task 10: implementer DONE (41 integration passed; 14 тестов удаления); review dispatched (sonnet); concern: FK document из financial_statement_meta/consolidation_document/ex_snapshot_item не чистятся — Срезы 3/4 (OQ про удаление после консолидаций, Task 12) +R-23 (design #36): Ruling: delete: блокировки задания -> файл -> документы; 409 после блокировок; expires_at безусловно; FK на document из будущих таблиц не чистятся (Срез 3: financial_statement_meta) — цена: IntegrityError 500 в Срезе 3 без очистки. -> OQ в Task 12 (удаление после консолидаций, ретеншн). +Task 10: minor T10.1 (deferred): before_value.documents отсортированы по id, а карточка GET — по (type, account, locator, id); T10.2 (deferred): test_delete_error_file проверяет user_id is not None, не равенство инициатору; T10.3 (deferred): test_mapping_loop_ignores_deleted_file — не гонка, ветка job is None в _map_candidate не проверена (реальных гоночных тестов 2); T10.4 (deferred): _wait_for_lock_waiter считает любой Lock-ожидатель в БД; T10.5 (deferred, теоретич.): job, вставленный между lock_jobs и lock_file, не получит expires_at (путей создания второго job нет); T10.6 (note): DELETE живёт в endpoints/files/read.py по брифу +Task 10: complete (commits e2fde90..1502173, review clean); tasks.md 4.2 отмечен +Task 11: dispatched (sonnet, BASE ea2423d, agent a55fd95eed08797d7) +Task 11: implementer DONE f361fae; e2e: с профилем services PASSED (4.12 с, ОСВ 66 -> PROCESSED, 66/2024/Q4, cumulative, MATCH, RECOGNIZED, 9 строк, DELETE 204), без профиля 1 skipped; make check 900 passed; make test-integration 437 passed; review dispatched (sonnet) +O-4: compose-проект arm-ka запущен из уже удалённого .wt/slice-1/docs/dev/docker-compose.yml; парсер поднят с --no-deps -p arm-ka; `make up` из другого worktree может пересоздать инфраструктуру +O-5: DELETE лишь ставит expires_at — каждый e2e-прогон оставляет ~2 объекта в реальном MinIO (ретеншн OQ-22) +O-6: образ tabular-parser:dev пересобран из main соседнего репозитория (~35 с); контейнер tabular-parser после прогона остановлен +Task 11: review: Important T11.1 (README: конкуренция за recognition.result queue group с контейнером arm-core профиля services/локальным make run) -> fix round 1 (resume implementer) +Task 11: minor T11.2 (deferred): assert deleted.status_code == 204 в finally может замаскировать причину падения; T11.3 (deferred): проверка порядка sorted(x)==x тавтологична, суммы не сверяются с golden; T11.4 (deferred): MATCH достигается по имени, не по ИНН (формулировка брифа вводит в заблуждение) — комментарий; T11.5 (deferred): _EngineStub не вызывает dispose; T11.6 (deferred): make e2e не проверяет инфраструктуру (check-infra) +Task 11: T11.1 fixed 96525f1; fix round 1/5 (1 addressed, 0 open; commits f361fae..96525f1) +Task 11: complete (commits ea2423d..96525f1, review clean); tasks.md 5.1 отмечен +Task 12: dispatched (sonnet, BASE 96525f1, agent ad5f8d93d4465dcf2) +Task 12: implementer DONE f9b8a21 (arm-core) + docs 45ee08c; OQ-110..116; design №40-50; unit 900, integration 437, e2e 1 passed c реальным парсером (4.3 с), fixtures 13+1skip; docker-build, web-check EXIT 0; openspec validate --strict оба репо ok. review dispatched (sonnet). Контроллер запустил make check-full (e2e skip: парсер остановлен) -> check-full-controller.log +Контроллер: make check-full (HEAD f9b8a21, парсер остановлен) EXIT 0: unit 900 passed; integration 437 passed; e2e 1 skipped (профиль services не поднят); dbml-check пуст; docker-build и web-check ok — лог check-full-controller.log +Task 12: review: Important T12.1 (негативное требование OQ-55 «обмен/маппинг не пишут audit_event» не покрыто тестом, трассировка ссылалась на несуществующие проверки) -> fix round 1 (resume implementer) +Task 12: minor T12.2 (-> final fix wave): README.md:147 «(OQ — см. …)» без номера (OQ-115); T12.3: README «Ограничения» S3 — ссылки OQ-22 и OQ-111; T12.4: README.md:462/CLAUDE.md «запускаются при NATS_ENABLED» — цикл маппинга ещё требует storage; строка NATS_ENABLED в таблице настроек README:636 не перечисляет цикл; T12.5: tasks.md 3.1 сигнатура counterparty_check(header, owner_inn, owner_names_normalized); T12.6: docs 09-delivery-plan «Итог make check-full» — сверить с итоговым прогоном (контроллер: EXIT 0 при e2e skip; отдельно e2e 1 passed с парсером); T12.7: qa-guide U16: до ERROR файл UPLOADED (без ack); T12.8: неточные ссылки трассировки в task-12-report.md (test_full_cycle_over_real_nats в inbox; test_dlq_moves_current_file_to_error; mapping_error проверяет test_file_card_equals_list_item) +Task 12: T12.1 fixed 24d556e (2 теста на OQ-55); fix round 1/5 (1 addressed; commits f9b8a21..24d556e); T12 minors T12.2-T12.8 -> final fix wave +Final review: dispatched (opus, agent aa604d1a3615c92a7) on 55e538c..24d556e + docs 5545ee5/45ee08c; re-review Task 12 (haiku) параллельно +Task 12: re-review: T12.1 addressed (watchdog-тест слабее: мутация ловится падением expire_deadlines, не assert аудита); Task 12: complete (commits 96525f1..24d556e + docs 45ee08c, review clean) +FINAL REVIEW (opus): вердикт Needs fixes; критических нет. Находки: +I-1: osv_entry без индексов на parent_entry_id и recognition_job_id (и stored_file.current_recognition_job_id = T4.2): DELETE /files/{id} на таблице ~200 тыс. строк — 8,6 с на документ из 1000 строк (измерение в psql на временной таблице), при ~3 млн строк ~2 мин -> HTTP-таймаут с удержанием блокировок. Требует DBML v5.16 + миграция 0004 — решение человека (источник истины — DBML). +M-1 (= T7.6 расширенная): mapping_error содержит str(ValidationError) (input_value) и текст SQLAlchemy/asyncpg ([SQL: ...], параметры) до 2000 символов — виден пользователю с DOCUMENT_VIEW. +M-2: api/.env.example: «больше — ответ 413» неверно (rejected[FILE_TOO_LARGE], 201/422). +M-3: DELETE ставит expires_at объекту результата по ссылке job.result_s3_object_id без проверки префикса; inbox переиспользует s3_object при конфликте (bucket,key) -> чужой upload может получить expires_at. +M-4 (наблюдение): суммарный размер multipart и число файлов не ограничены (спул Starlette) — лимит ingress платформы; упомянуть в README/OQ. +R-24 (design #51): Ruling: I-1 НЕ чинится в этой ветке — нужен DBML v5.16 + миграция 0004 (индексы osv_entry(parent_entry_id), osv_entry(recognition_job_id), stored_file(current_recognition_job_id)); заведено как OQ + follow-up issue с блокером «до прода / до подсреза 2d (UI удаления)»; цена: DELETE деградирует на большой таблице. РЕШЕНИЕ ЧЕЛОВЕКА. +Fix wave (ONE fixer, fresh opus): M-1, M-2, M-3, M-4(README), T7.2 (сортировка entries по level перед пачками), T12.2-T12.8, гигиена тестов T9.3, T8.6, T11.2, T2.2, T6.1, T5.1, T1.1; OQ для I-1 в docs. +Final fix wave: dispatched (opus fresh, BASE 24d556e, agent ac9672d78b1c518c4) +Final fix wave: DONE (583edf2, 2ac19c6, d14295b, 75feab6 + docs 8c73fe9); make check 901 passed; integration|fixtures 448 passed, 1 skipped; openspec validate ok оба репо; scoped re-review dispatched +Контроллер на HEAD 75feab6: e2e с реальным tabular-parser 1 passed (4.01 с); -m fixtures 14 passed (с e2e); парсер остановлен, инфраструктура Up +Re-review fix wave (sonnet): all findings addressed, new breakage none (130 passed focused). +FINAL STATUS findings: M-1 fixed 583edf2; T7.2 fixed 583edf2; M-3 fixed 2ac19c6; M-2, M-4, T12.2-T12.8 fixed d14295b (+docs 8c73fe9); T9.3/T8.6/T11.2/T6.1/T5.1/T1.1 fixed 75feab6; T2.2 confirmed already ok; I-1 deferred (R-24, OQ-117, DBML v5.16 + миграция 0004, решение человека). +FINAL make check-full на HEAD 75feab6 (контроллер): EXIT 0 — unit 901 passed; integration 444 passed; e2e 1 skipped (профиль не поднят); docker-build и web-check ok; e2e с реальным tabular-parser 1 passed (4.01 с); -m fixtures 14 passed. -- 2.47.2 From 6ccb99194d76a926764e59c38acaccbcc6bf819d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:56:28 +0400 Subject: [PATCH 35/35] =?UTF-8?q?feat(model):=20=D0=B8=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=8B=20osv=5Fentry=20=D0=B8=20stored=5Ffile=20?= =?UTF-8?q?=D0=B2=20v5.15=20(I-1,=20OQ-117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ORM: ix_osv_entry_parent_entry_id, ix_osv_entry_recognition_job_id (models/osv.py), ix_stored_file_current_recognition_job_id (models/files.py) — по DBML v5.15 п. 4. - Миграция 0003_dbml_v5_15: create_index в upgrade, drop_index в downgrade. - tests/test_migrations.py: индексы есть после upgrade head (btree, не unique), исчезают после downgrade на 0002 и возвращаются при повторном upgrade. - openspec change: design.md решение №53 (I-1 закрыт в v5.15), Migration Plan; дельта data-model — 0003 создаёт индексы, откат на шаг их удаляет. - README «Ограничения Среза 2c»: убран пункт об отсутствии индексов. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 3 -- api/alembic/versions/0003_dbml_v5_15.py | 13 +++++- api/src/models/files.py | 6 ++- api/src/models/osv.py | 3 ++ api/tests/test_migrations.py | 41 +++++++++++++++++++ .../changes/slice-2c-osv-ingest/design.md | 5 ++- .../specs/data-model/spec.md | 7 ++-- 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8adfb19..f3f15e1 100644 --- a/README.md +++ b/README.md @@ -531,9 +531,6 @@ S3 (storage); без S3 — предупреждение в логе, цикл - Объекты 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», «ГТД» diff --git a/api/alembic/versions/0003_dbml_v5_15.py b/api/alembic/versions/0003_dbml_v5_15.py index 6b51c48..ef7b3c0 100644 --- a/api/alembic/versions/0003_dbml_v5_15.py +++ b/api/alembic/versions/0003_dbml_v5_15.py @@ -1,4 +1,5 @@ -"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13). +"""DBML v5.15: osv_data_type += NU, app_user.email в нижнем регистре (design D-13, решение №13), индексы для проверок FK +при удалении файла и обхода дерева строк ОСВ (v5.15 п.4, I-1, OQ-117). `ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип osv_data_type со значениями v5.14 (строк `osv_entry` с `data_type = 'NU'` быть не должно; иначе downgrade падает на USING — это осознанно, как в 0002). @@ -24,15 +25,25 @@ depends_on: str | Sequence[str] | None = None SCHEMA = "dkb" OSV_DATA_TYPE_V5_14 = ("BU", "CURRENCY", "QUANTITY", "SUM") +# v5.15 п.4: (имя, таблица, колонка) — обычные btree-индексы, имена как в ORM +FK_LOOKUP_INDEXES = ( + ("ix_osv_entry_parent_entry_id", "osv_entry", "parent_entry_id"), + ("ix_osv_entry_recognition_job_id", "osv_entry", "recognition_job_id"), + ("ix_stored_file_current_recognition_job_id", "stored_file", "current_recognition_job_id"), +) def upgrade() -> None: # порядок значений — как в DBML v5.15 (сверка enum'ов сравнивает упорядоченные списки) op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type ADD VALUE IF NOT EXISTS 'NU' AFTER 'BU'") op.execute(f"UPDATE {SCHEMA}.app_user SET email = lower(email) WHERE email <> lower(email)") + for name, table, column in FK_LOOKUP_INDEXES: + op.create_index(name, table, [column], schema=SCHEMA) def downgrade() -> None: + for name, table, _column in FK_LOOKUP_INDEXES: + op.drop_index(name, table_name=table, schema=SCHEMA) op.execute(f"ALTER TYPE {SCHEMA}.osv_data_type RENAME TO osv_data_type_old") postgresql.ENUM(*OSV_DATA_TYPE_V5_14, name="osv_data_type", schema=SCHEMA).create(op.get_bind(), checkfirst=False) op.execute( diff --git a/api/src/models/files.py b/api/src/models/files.py index 3241f80..b856173 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -51,7 +51,11 @@ class StoredFile(UUIDPrimaryMixin, Base): """Физический файл одной загрузки; что внутри — `document` (1:N). Первичка не редактируется (ТЗ 8.2).""" __tablename__ = "stored_file" - __table_args__ = (Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),) + __table_args__ = ( + Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"), + # v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла + Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"), + ) legal_entity_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 0f007b4..fbebe82 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -24,6 +24,9 @@ class OsvEntry(UUIDPrimaryMixin, Base): __table_args__ = ( Index("uq_osv_entry_document_id_row_number", "document_id", "row_number", unique=True), Index("ix_osv_entry_owner_account_counterparty", "owner_legal_entity_id", "account_id", "counterparty_id"), + # v5.15 п.4 (OQ-117): проверки FK при удалении документа/файла (в том числе self-FK) и обход дерева строк + Index("ix_osv_entry_parent_entry_id", "parent_entry_id"), + Index("ix_osv_entry_recognition_job_id", "recognition_job_id"), ) recognition_job_id: Mapped[uuid.UUID] = mapped_column( diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 505eeb0..a8e74d0 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -40,6 +40,12 @@ V5_14 = "0002_dbml_v5_14" PREVIOUS = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" +# v5.15 п.4 (I-1, OQ-117): индексы для проверок FK при удалении файла/документа и обхода дерева строк ОСВ +V5_15_INDEXES = { + "ix_osv_entry_parent_entry_id": ("osv_entry", "parent_entry_id"), + "ix_osv_entry_recognition_job_id": ("osv_entry", "recognition_job_id"), + "ix_stored_file_current_recognition_job_id": ("stored_file", "current_recognition_job_id"), +} async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]: @@ -270,6 +276,41 @@ async def test_downgrade_one_step_fails_while_nu_rows_exist(scratch_db_url: str) await engine.dispose() +async def _v5_15_indexes(conn: AsyncConnection) -> dict[str, str]: + rows = ( + await conn.execute( + text("SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND indexname = ANY(:names)"), + {"names": list(V5_15_INDEXES)}, + ) + ).all() + return {row[0]: row[1] for row in rows} + + +@pytest.mark.integration +async def test_upgrade_0003_creates_fk_lookup_indexes(scratch_db_url: str) -> None: + """0003 создаёт обычные (не unique) btree-индексы v5.15 п.4; откат на 0002 удаляет, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + indexes = await _v5_15_indexes(conn) + assert set(indexes) == set(V5_15_INDEXES) + for name, (table, column) in V5_15_INDEXES.items(): + assert indexes[name] == f"CREATE INDEX {name} ON dkb.{table} USING btree ({column})" + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_14) + async with engine.connect() as conn: + assert await _v5_15_indexes(conn) == {} + await engine.dispose() + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert set(await _v5_15_indexes(conn)) == set(V5_15_INDEXES) + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0003_lowercases_existing_emails(scratch_db_url: str) -> None: """0003 приводит `app_user.email` к нижнему регистру; уже строчные и NULL не затрагиваются.""" diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index a7d1f64..13ae891 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -147,7 +147,9 @@ rows без TOTAL, у каждого не-корня есть родитель, `0003_dbml_v5_15`: `ALTER TYPE osv_data_type ADD VALUE 'NU' AFTER 'BU'` (downgrade — пересоздание типа, как в 0002); `UPDATE app_user SET email = lower(email)` (при дублях по регистру миграция падает — осознанно; downgrade данные не -возвращает). `HEAD` в тестах миграций → `0003_dbml_v5_15`. +возвращает); индексы v5.15 п.4 `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`, +`ix_stored_file_current_recognition_job_id` (downgrade — `drop_index`; I-1, OQ-117, решение №53). `HEAD` в тестах +миграций → `0003_dbml_v5_15`. ## Open Questions @@ -210,3 +212,4 @@ rows без TOTAL, у каждого не-корня есть родитель, | 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) | +| 53 | 2026-10-06 | I-1 закрыт сессией в v5.15: три индекса `osv_entry(parent_entry_id)`, `osv_entry(recognition_job_id)`, `stored_file(current_recognition_job_id)` добавлены в DBML v5.15 (п. 4 changelog, docs) и в ORM + миграцию `0003_dbml_v5_15` (`ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`, `ix_stored_file_current_recognition_job_id`); v5.16 и миграция 0004 не заводятся — v5.15 ещё не слит. Отменяет №51 в части «индексы в этой ветке не добавляются»; OQ-117 закрыт | поручение сессии (контроллера) после final review; DBML остаётся источником истины — индексы сначала в DBML, сверка `make dbml-check` пуста | минимальная: обычные btree-индексы, замедляют только вставку строк ОСВ; откат — `drop_index` в downgrade 0003 | diff --git a/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md index d62d583..b0c3da5 100644 --- a/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md +++ b/openspec/changes/slice-2c-osv-ingest/specs/data-model/spec.md @@ -40,8 +40,9 @@ ORM-метаданные SHALL содержать ровно 43 таблицы ### Requirement: Миграции применяются с нуля и откатываются Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` → `0003_dbml_v5_15` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) -на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type` и приводить -`app_user.email` к нижнему регистру; `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через +на пустой базе PostgreSQL 16 (требуется ≥ 15); `0003` SHALL добавлять `NU` в `osv_data_type`, приводить +`app_user.email` к нижнему регистру и создавать индексы `ix_osv_entry_parent_entry_id`, `ix_osv_entry_recognition_job_id`, +`ix_stored_file_current_recognition_job_id` (DBML v5.15 п. 4); `downgrade` до `base` SHALL удалять всё созданное (новые значения enum — через пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок. #### Scenario: Полный цикл @@ -51,7 +52,7 @@ enum'ы, таблицы, ограничения, индексы и послед #### Scenario: Откат на один шаг - **WHEN** после `upgrade head` выполняется `alembic downgrade 0002_dbml_v5_14` -- **THEN** `osv_data_type` не содержит `NU`; регистр `app_user.email` не возвращается (данные) +- **THEN** `osv_data_type` не содержит `NU`, индексов v5.15 п. 4 нет; регистр `app_user.email` не возвращается (данные) #### Scenario: Автогенерация ничего не находит - **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД -- 2.47.2