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
211 lines
40 KiB
Markdown
211 lines
40 KiB
Markdown
# 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 |
|