docs: README/CLAUDE.md Среза 2c, tasks.md

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 00:11:52 +04:00
parent 96525f1ace
commit f9b8a21410
6 changed files with 228 additions and 18 deletions

View File

@ -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_<hex>` с миграциями,
PostgreSQL/NATS), `fixtures` (реальные данные из `../fixtures`) и `e2e` (поверх `integration`: нужен tabular-parser из
профиля `services`, иначе skip; `make test-integration` его исключает, гонять — `make e2e`); БД сессии — свежая `armcore_pytest_<hex>` с миграциями,
удаляется в конце; `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).

177
README.md
View File

@ -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/<file_id>" -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)

View File

@ -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`.
---

View File

@ -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=<locator>: строки без родителя …` → `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 |

View File

@ -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` своего филиала

View File

@ -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`.