Andrey Epifancev f9b8a21410 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
2026-10-06 00:11:52 +04:00

211 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |
| 14 | 2026-10-05 | R-1: golden-прогон 82 снимков — второй коммит Task 8 (`tests/test_mapping_golden.py`) | в плане нет отдельной задачи под tasks 3.4; нужен полный `map_file` | перенос теста |
| 15 | 2026-10-05 | R-2: `make_entries_from_result` создаётся в Task 9, не в Task 2 | зависит от `build_entries` | нет |
| 16 | 2026-10-05 | R-3: `EntryDraft.counterparty_id`, проставляется в цикле маппинга через `CounterpartyIndex.resolve` для `COUNTERPARTY` + `BU/SUM`; `build_entries` без resolve | спека «Резолв контрагента» не сводилась с сигнатурой плана | сигнатура на callable (D-6) |
| 17 | 2026-10-05 | R-4: inbox при `done`/`needs_check` переводит файл `UPLOADED → PROCESSING` (текущее задание) | core NATS без JetStream: ack может потеряться, иначе файл с готовым результатом не попадёт в маппинг | убрать ветку |
| 18 | 2026-10-05 | R-5: ack/результат ставят `PROCESSING` только из `UPLOADED`; `ERROR`/`SUPERSEDED`/`PROCESSED` не перезаписываются | запоздавший ack не воскрешает файл после timeout | параметр `only_from` |
| 19 | 2026-10-05 | R-6: `DocumentOut.warnings` — `metrics.mapping_warnings` задания целиком, `group_schema` — по `locator` документа | в DBML нет привязки предупреждения к документу | фильтрация по листу |
| 20 | 2026-10-05 | R-7: в Task 1 правится workspace `CLAUDE.md` (вне git): только версия DBML | по плану Task 1 | откат текста |
| 21 | 2026-10-05 | R-8 (Task 3): 503 `STORAGE_UNAVAILABLE` через `DomainError` (`{"detail":{"code","message"}}`), а не строкой `HTTPException`; строка остаётся только у 413 внутри `read_limited` (превращается в `rejected[FILE_TOO_LARGE]`) | спека и Global Constraints плана: ошибки — `DomainError`; бриф противоречил | формат тела 503 |
| 22 | 2026-10-05 | R-9 (Task 3): сбой S3 на одном файле → этот файл в `rejected` с `STORAGE_UNAVAILABLE`, откатывается его SAVEPOINT, принятые остаются; если отклонены все — 422 | модель частичного успеха D-4; клиент видит причину по файлу | вернуть 503 при отказе всех |
| 23 | 2026-10-05 | R-10 (Task 3): загрузка файла — SAVEPOINT внутри транзакции запроса (не отдельная транзакция на файл, как в тексте спеки); недоменная ошибка БД валит весь запрос | указано в брифе плана; атомарность ответа | отдельные сессии на файл |
| 24 | 2026-10-05 | R-11 (Task 3): `pg_advisory_xact_lock(ЮЛ, sha256)` против двойного клика — дубль не проскакивает; `requested_at = now` у задания; `size_bytes` у файла; `.jpeg` → JPG; `operation_id` у маршрута (OpenAPI) | гонка дедупа подтверждена тестом; FIFO outbox идёт по `requested_at` | убрать блокировку |
| 25 | 2026-10-05 | R-12 (Task 5): корень листа — первая строка `level == 0` без родителя (в голден-снимках ровно один; в двух пустых листах корня нет, `root = None`); имя шапки, пустое после `normalize_alias` (только ОПФ/пунктуация), без ИНН → `counterparty_check = UNKNOWN`; ИНН шапки сравнивается по цифрам; допуск `opening_matches` ±0.01 включительно по дебету и кредиту | шапка без идентифицирующего имени ничего не доказывает и не должна давать ложный MISMATCH | вернуть MISMATCH для пустой нормализованной шапки |
| 26 | 2026-10-05 | R-13: «20 строк» в плане (golden `ОСВ_66 за 2024`) исправлено на 9 (10 строк снимка минус TOTAL); критерий — «строк результата − 1» | в снимке 10 строк | нет |
| 27 | 2026-10-05 | R-14 (Task 6): ИНН ищется точно после `normalize_inn` (пробелы), мусорные значения проваливаются к имени; пустая нормализация (имя из одних ОПФ) не попадает в индекс; при единственном точном совпадении имени ЮЛ побеждает, даже если нормализованное имя неоднозначно (неоднозначность оценивается по шагам, D-8) | буквальное чтение §3 п. 4 «на любом шаге > 1 → NULL» | поменять приоритет шагов |
| 28 | 2026-10-05 | R-15 (Task 7): после повторяемой неудачи маппинга цикл спит `MAPPING_POLL_SECONDS` (`process_once` возвращает False), а не крутит попытки без паузы | иначе 3 попытки за миллисекунды превращают кратковременный сбой S3/БД в ERROR всех файлов | вернуть мгновенный повтор / задержка в metrics |
| 29 | 2026-10-05 | R-16 (Task 7): статус документа считается по предупреждениям его листа (повтор счёта, сироты, нарастающая по Q1); `metrics.mapping_warnings` — общий список файла | предупреждение одного листа не должно ронять чистый лист другого счёта | передавать весь список |
| 30 | 2026-10-05 | R-17 (Task 7): «нарастающая по данным Q1» даёт предупреждение маппинга, поэтому документ `NEEDS_CHECK` (буквально D-7/D-9: `RECOGNIZED` только без предупреждений) | спека; неопределённая нарастающая требует взгляда аналитика | исключить это предупреждение из правила статуса |
| 31 | 2026-10-05 | R-18 (Task 7): результат с `job_id`/`file_id` ≠ заданию — неповторяемая ошибка; ошибки S3, кроме «нет объекта», — повторяемые; нелистовые документы (BALANCE/INCOME/OTHER) игнорируются без предупреждения; сироты → предупреждение + `NEEDS_CHECK`; ключ с сегментами `.`/`..`/пустыми отвергается | защита от подмены; ОСВ-only подсрез | убрать проверки |
| 32 | 2026-10-05 | R-19 (Task 8): автозамена берёт `pg_advisory_xact_lock('supersede:{legal_entity_id}')`, затем `FOR UPDATE` старых файлов по `id`, документы перечитываются после блокировки (`confirmed` видит зафиксированное подтверждение); документ без года/квартала в автозамене не участвует | в READ COMMITTED два параллельных маппинга не видят документов друг друга и оба остались бы действующими; NULL-период не доказывает «тот же период» | убрать блокировку / включить NULL-периоды |
| 33 | 2026-10-05 | R-20 (Task 9): в `JobBrief` добавлено `mapping_error` (из `metrics.mapping_error`); 409 `DOCUMENT_NOT_CONFIRMABLE` для статуса вне {RECOGNIZED, NEEDS_CHECK}; `FILE_SUPERSEDED` проверяется раньше `ALREADY_CONFIRMED`; документ/файл исчезли за время ожидания блокировки → 404 | файл `ERROR` после неудачного маппинга иначе необъясним по карточке; цель среза — объяснимые статусы | убрать поле/код |
| 34 | 2026-10-05 | R-21 (Task 9): `confirm` берёт блокировки «файл → документ» и перечитывает оба под `FOR UPDATE`; чтение файлов — методы `PrimaryFilesService`, документов — `DocumentService`; задание для `DocumentOut` — текущее задание файла; список файлов `ORDER BY uploaded_at DESC NULLS LAST, id`; у моделей нет `relationship()`, поэтому job/документы грузятся пачками (число запросов не зависит от числа файлов, проверено тестом) | согласовано с порядком блокировок автозамены; Review Focus 5 | переупорядочить блокировки |
| 35 | 2026-10-05 | R-22 (Task 9): `test_openapi_routes` отвергает лишние маршруты под `/legal-entities`, `/files`, `/documents` (Task 10 обязан добавить DELETE в `EXPECTED_ROUTES`) | новый маршрут сначала должен попасть в спеку | ослабить тест |
| 36 | 2026-10-05 | R-23 (Task 10): удаление файла блокирует все его задания (по id) → файл → документы, 409 проверяются после блокировок; файл исчез за время ожидания → 404; `expires_at = now` безусловно у `s3_object` файла и объектов результатов (объекты не разделяются между файлами); FK на `document` из `financial_statement_meta`/`consolidation_document`/`ex_snapshot_item` не чистятся (в Срезе 2c недостижимы; `CONFIRMED`-запрет закрывает консолидации; ББ/ОФР — очищать `financial_statement_meta` в Срезе 3); `FILE_HAS_CONFIRMED_DOCUMENTS` проверяется раньше `FILE_SUPERSEDES_OTHERS` | порядок блокировок задание → файл общий для обмена/маппинга | IntegrityError 500 в Срезе 3 без очистки |
| 37 | 2026-10-05 | Task 11: `make test-integration` = `pytest -m "integration and not e2e"`, e2e идёт целью `make e2e` и отдельным шагом `check-full` после `test-integration` | e2e несёт маркер `integration` (требование брифа); без исключения он запускался бы в `check-full` дважды | двойной прогон e2e при поднятом профиле (секунды); без профиля — два skip |
| 38 | 2026-10-05 | Task 11: профиль `services` для парсера поднимается как `-p arm-ka … up -d --build --no-deps tabular-parser` (README); предусловие теста — `GET localhost:8001/healthcheck` = 200 и `nats=ok`, иначе skip «профиль services не поднят» | проект `arm-ka` поднят из другого файла compose (worktree среза 1): без `--no-deps` compose мог пересоздать postgres/nats/minio при расхождении конфигурации | пересоздание инфраструктуры и потеря dev-данных |
| 39 | 2026-10-05 | Task 11: e2e подменяет `async_session_maker` в `src.app` и `src.db.db` и `engine` в `src.app` (как `test_exchange_lifespan_nats`), `MAPPING_POLL_SECONDS`/`OUTBOX_POLL_SECONDS` = 1; ЮЛ создаётся с именем `АО "Русский бисквит"`, ИНН `3528052718` — в шапке ОСВ ИНН нет, MATCH честный по имени (D-8) | нужна временная БД вместо рабочей и быстрый прогон; шапка снимка: `organization_name = АО "Русский бисквит"`, `organization_inn = null` | e2e читает/пишет не ту БД (рабочая `armka`) либо ложный MISMATCH |
| 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 |