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