From f9b8a214107b8c7b6a1af6485a416e5861909bd9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:11:52 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20README/CLAUDE.md=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=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`.