OpenSpec: change slice-2c-osv-ingest (загрузка файлов, цикл маппинга, документы — спека)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
66eb214a66
commit
239d5bc270
2
openspec/changes/slice-2c-osv-ingest/.openspec.yaml
Normal file
2
openspec/changes/slice-2c-osv-ingest/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-05
|
||||
173
openspec/changes/slice-2c-osv-ingest/design.md
Normal file
173
openspec/changes/slice-2c-osv-ingest/design.md
Normal file
@ -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=<locator>: счёт <код> уже есть в листе <locator первого>`, первый документ → `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 |
|
||||
77
openspec/changes/slice-2c-osv-ingest/proposal.md
Normal file
77
openspec/changes/slice-2c-osv-ingest/proposal.md
Normal file
@ -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.
|
||||
@ -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`)
|
||||
@ -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** сверка против нового файла падает с сообщением о версии до сравнения таблиц
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`, ничего не изменилось
|
||||
@ -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 = <service.name>`; только при наличии `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/<file_id>/<job_id>/result.json}` и
|
||||
`Nats-Msg-Id = result:<job_id>:1`
|
||||
- **THEN** задание получает `status = done`, `result_msg_id = result:<job_id>: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`
|
||||
68
openspec/changes/slice-2c-osv-ingest/tasks.md
Normal file
68
openspec/changes/slice-2c-osv-ingest/tasks.md
Normal file
@ -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`.
|
||||
Loading…
x
Reference in New Issue
Block a user