Andrey Epifancev 4cc08cc3de fix(consolidation): ревью Task 7 — тест надмножества блокировок хука маппинга, устойчивость загрузки к ошибке перехода
T7.0: двухсессионный тест маппинг ∥ перевод версии в DOCUMENTS_PROCESSING (ловит мутант
statuses={DOCUMENTS_PROCESSING}). T7.1 (R-10, design №43): ошибка перехода после загрузки логируется,
принятые файлы и 201 сохраняются. T7.3: лог пропуска без uploaded_by только при наличии NEEDS_CHECK.
T7.4: TransactionFactory импортируется из session_audit. T7.2/T7.5: дополнена строка design №39.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 05:37:39 +04:00

231 lines
49 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: Срез 4 — консолидация и сводная ОСВ
## Context
Обследование 06.10.2026 (`.wt/slice-4`): модели шести таблиц консолидации и `required_document_rule` есть (1:1 с DBML
v5.15), сервисов/репозиториев/эндпоинтов/seed консолидаций нет; `CONSOLIDATION_VIEW/EDIT` выданы ролям, но не
используются; `domain/policy.py` знает только видимость справочников; `DocumentService.confirm` не знает о версиях;
`supersede_previous` берёт advisory-блокировку ЮЛ и старые файлы `FOR UPDATE`; `CounterpartyIndex.resolve` возвращает
только `UUID | None`; `openpyxl` уже в зависимостях (парсер ББ/ОФР). Фикстуры: ОСВ РБ 60/62 за три квартала 2024 и
01/58/66/67/76 только за Q3; Счастливочная 60/62/76 за три квартала, 01/02 за Q3.
Решения владельца 06.10.2026: объём — бэкенд + API + xlsx; OQ-03 — пропуск квартала даёт пробел по паре
(ЮЛ, счёт), ключ склейки (счёт, контрагент); OQ-19 — блокировка закрывает все правки версии, перехват любому с
`CONSOLIDATION_EDIT` по причине; статусы Среза 4 — автоматически по событиям документов + ручные `CANCELLED` и возврат
в `INITIATED`; привязка документов к версии — явная, при подтверждении; экспорт — один лист как экран `30` плюс лист
пробелов; признак автоматического сопоставления контрагента — в API (хранится).
## Goals / Non-Goals
**Goals:** сводная ОСВ — чистая функция от версии (DECISION-5), одинаковая для API и экспорта; комплект версии
объясним по `consolidation_document`; каждая правка версии защищена статусом и блокировкой; тест на фикстурах РБ из
плана проходит. **Non-Goals:** см. proposal.
## Decisions
### D-1. Сводная ОСВ: одна выборка + чистый домен
`repositories/consolidations.py:load_osv_rows(version_id)` одним запросом отдаёт строки `osv_entry` привязанных
ОСВ-документов версии (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`) вместе с документом и ЮЛ.
`domain/consolidated_osv.py:build(version, members, documents, entries, index)` без БД: выбор документов на пару
(ЮЛ, счёт документа), приведение периодов, пробелы, признак, дорезолв, фильтры, порядок. Страница режется в памяти в
сервисе; экспорт использует тот же результат. *Альтернативы:* агрегат в SQL (логика не тестируется, расходится с
Python-резолвом) и таблица кэша (запрещена DECISION-5) — отвергнуты. Цена: весь свод версии в памяти на запрос; для
групп до десятков тысяч строк приемлемо (OQ-121 — порог, после которого нужен кэш процесса).
### D-2. Выбор документов на пару (ЮЛ, счёт) и приведение периодов (§2, OQ-03)
`domain/consolidation_periods.py:select_sources(version_quarter, documents) -> Sources | Gap`:
- есть привязанный документ с `is_cumulative = true` и `period_quarter = quarter версии` → он один, как есть;
- иначе нужны привязанные квартальные документы за каждый квартал `Q1..Qn` (`n` = квартал версии); для версии за Q1
квартальный документ Q1 равнозначен нарастающему; любого квартала нет → `Gap(missing_quarters)`, строк нет;
- нарастающий и квартальные одновременно → нарастающий (он полнее по определению).
Склейка квартальных строк по ключу `(account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn))`:
`opening_*` из строки Q1, `turnover_*` = Σ по кварталам, `closing_*` из строки Qn; отсутствующая в квартале строка
= нули; две строки с одним ключом внутри квартала суммируются (решение без обсуждения #1). `row_number` строки свода =
минимальный `row_number` среди источников последнего квартала (порядок как в файле Qn). Договоры (`group_type =
CONTRACT`) в свод не входят, строка COUNTERPARTY уже содержит их сумму.
### D-3. Признак контрагента и дорезолв
`counterparty_id` строки свода = первый непустой среди исходных строк (при расхождении между кварталами — из самого
позднего квартала, строки с другим значением перечисляются в `warnings` строки). Пустой → `CounterpartyIndex.resolve`
на лету, результат не пишется в БД: `match_stored = false`, `match_kind` = шаг резолва. Признак: `GSK`, если ЮЛ в
`consolidation_member` версии; `GK`, если `legal_entity.company_group_id = consolidation.company_group_id`; иначе
`EXTERNAL` (и при пустом контрагенте). Справочник читается целиком без policy (как в 2c).
### D-4. `counterparty_match_kind` хранится (DBML v5.16)
Маппинг пишет `INN | NAME | ALIAS` по шагу, на котором нашёлся единственный кандидат (`resolve` возвращает
`Resolution(entity_id, kind)`); ручное связывание — `MANUAL`, автоприменение — `PROPAGATED`; `NULL` при пустом
`counterparty_id`. Миграция `0004` делает backfill SQL'ем: ИНН строки (без пробелов) = `legal_entity.inn` → `INN`;
`trim(name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS` (приближение: ручных сопоставлений
до Среза 4 не было). *Альтернатива* — вычислять на чтение: после ручного связывания появляется алиас, и ручное
неотличимо от `ALIAS`.
### D-5. Явная привязка документов (`consolidation_document`)
Документ **подходит** версии `V`, если `legal_entity_id ∈ members(V)`, `period_year = V.year`, `period_quarter` не
NULL и: `is_cumulative` (или NULL — ББ/ОФР всегда нарастающие) → `period_quarter = V.quarter`; квартальный →
`period_quarter ≤ V.quarter`. Привязываются только `CONFIRMED` документы незаменённых файлов. Точки привязки:
подтверждение документа (ко всем версиям «в работе»), создание версии и добавление ЮЛ в периметр (подхват: на одну
тройку (тип, счёт, квартал) — документ с самым поздним `confirmed_at`). Точки отвязки: автозамена файла (документы
заменённого файла — из всех версий «в работе»), удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются.
При привязке документ с той же тройкой у того же ЮЛ в той же версии заменяется (страховка от гонки автозамены).
Удаление файла не меняется: привязываются только `CONFIRMED`, а их файл удалить уже нельзя (OQ-110 закрыт для
Среза 4).
### D-6. Автоматика статусов Среза 4 (§9.1)
| Событие | Переход | `changed_by` |
|---|---|---|
| загрузка с `consolidation_version_id`, принят ≥ 1 файл | `INITIATED → DOCUMENTS_PROCESSING` | загрузивший |
| маппинг создал документ `NEEDS_CHECK`, подходящий версии | `DOCUMENTS_PROCESSING → DATA_CORRECTION` | `stored_file.uploaded_by` |
| подтверждение документа, привязанного к версии | `INITIATED`/`DOCUMENTS_PROCESSING → DATA_CORRECTION` | подтвердивший |
| `POST …/status {CANCELLED, comment}` | любой «в работе» → `CANCELLED` | пользователь |
| `POST …/status {INITIATED}` | `DOCUMENTS_PROCESSING`/`DATA_CORRECTION → INITIATED` | пользователь |
Загрузка и подтверждение блокировкой не ограничиваются (решение владельца). Маппинг без `uploaded_by` переход
пропускает с записью в лог (`changed_by NOT NULL`; решение без обсуждения #2). `CANCELLED` снимает активную
блокировку (#3). Возврат в `INITIATED` привязки сохраняет (#4). Остальные переходы — 409 `TRANSITION_NOT_ALLOWED`.
Каждый переход — `consolidation_status_history` + `audit_event(CONSOLIDATION_STATUS_CHANGE)`.
### D-7. Блокировка
`consolidation_lock` с частичным UNIQUE `WHERE released_at IS NULL`. `POST …/lock` без тела: активной нет → создать;
своя → вернуть её (идемпотентно); чужая → 409 `LOCKED_BY_OTHER` (`locked_by`, `locked_at` в `detail`). С
`takeover_reason` (непустой, ≤ 1000) при чужой: старая `released_at = now`, новая с `taken_over_from`,
`audit_event(CONSOLIDATION_LOCK_TAKEOVER)`. `DELETE …/lock`: своя → `released_at = now`,
`audit_event(CONSOLIDATION_LOCK, after.released = true)`; чужая → 409 `NOT_LOCK_OWNER`; нет активной → 204
(идемпотентно). Истечения по времени нет (OQ-119). Гонка двух `POST`: второй ловит `IntegrityError` уникального индекса
→ 409 `LOCKED_BY_OTHER` (как `constraint_races` в 2c).
### D-8. Политика редактируемости
`domain/policy.py`: `IN_WORK_STATUSES = {INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION}`;
`ensure_version_editable(version, active_lock, user_id)` → 409 `VERSION_NOT_EDITABLE` (статус не в работе), 409
`LOCK_REQUIRED` (нет активной), 409 `LOCKED_BY_OTHER` (чужая). Применяется к `PUT …/members`, `POST …/status`,
`POST …/osv/entries/{id}/counterparty`. Видимость консолидации — `company_groups_visible` по её ГК; чужая — 404
`CONSOLIDATION_NOT_FOUND`/`VERSION_NOT_FOUND`. Порядок: право → видимость → статус/блокировка → операция.
### D-9. Порядок блокировок строк
Правка версии: `consolidation_version FOR UPDATE` первой, затем остальные строки версии. Подтверждение документа:
файл → документ → версии (по возрастанию `id`, `FOR UPDATE`). Автозамена: advisory ЮЛ → старые файлы → версии по
`id`. Создание версии: `consolidation` по `(company_group_id, year, quarter)` защищена UNIQUE — гонка → 409
`CONSOLIDATION_EXISTS`. Единый порядок «файл → документ → версия» исключает встречные захваты.
### D-10. Ручное сопоставление (§3, ТЗ DOC-221278 п. 7–13)
Одна транзакция: строка принадлежит документу, привязанному к версии (иначе 404 `ENTRY_NOT_FOUND`); выбранное ЮЛ
существует (без policy); `normalized = normalize_alias(counterparty_name)`; пустой → алиас не создаётся; владельцы
`normalized` среди алиасов ≠ выбранному: без `move_alias` → 409 `ALIAS_TAKEN` (`owners[]`), с `move_alias` → их
алиасы удаляются (`audit LEGAL_ENTITY_CHANGE` на каждый, как в Срезе 1); алиаса у выбранного нет → создаётся
(`audit LEGAL_ENTITY_CHANGE`). Строке: `counterparty_id`, `counterparty_match_kind = MANUAL`; `counterparty_name/inn`
не трогаются. Автоприменение: строки всех ОСВ-документов, привязанных к версии, с `counterparty_name.strip()` тем же,
`counterparty_id IS NULL`, ИНН пустой или равный ИНН выбранного → `counterparty_id`, `PROPAGATED`. Один
`audit_event(OSV_COUNTERPARTY_MAP, object = osv_entry, consolidation_version_id)` с before/after и `propagated_entry_ids`.
Права: `CONSOLIDATION_EDIT` + своя блокировка; `DIRECTORY_DELETE` для переноса алиаса не требуется (отступление от
`aliases.remove`, решение владельца 06.10.2026). Перевязка уже связанной строки разрешена (before хранит прежнее).
### D-11. Кандидаты
`GET …/counterparty-candidates?q=&scope=group|all&entry_id=`: `group` (по умолчанию) — ЮЛ периметра и ЮЛ той же ГК,
`q` необязателен; `all` — весь справочник без policy, `q` ≥ 3 символов (422 `QUERY_TOO_SHORT`, #5). Поиск: ИНН по
префиксу, имя и алиасы — подстрока без регистра. Каждый кандидат: `id`, `inn`, `name`, `company_group`, `gsk`, `gsz`,
`counterparty_type` относительно версии, `recommended` (нормализованное имя/алиас совпало с `normalize_alias`
имени строки `entry_id`, если он передан). Рекомендуемые первыми, не больше 100 строк.
### D-12. `period_check` не меняется
Подходящесть документа версии — функция (документ, версия) и считается в D-5; записывать её в `document.period_check`
нельзя: один документ может входить в несколько версий (разные периметры и кварталы). `period_check` остаётся
`UNKNOWN` до решения OQ-120 (показывать на карточке файла «подходит версиям: …»).
### D-13. Чек-лист
`GET …/checklist`: строки — `consolidation_member` по имени ЮЛ; колонки — `required_document_rule` подхода версии
(`BASIC`: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90), порядок: ББ, ОФР, счета по коду. Ячейка:
`CONFIRMED` — пара полностью покрыта привязанными документами (D-2 без пробела; для ББ/ОФР — документ за квартал
версии); иначе `NEEDS_CHECK`/`RECOGNIZED` — есть неподтверждённый подходящий документ (приоритет `NEEDS_CHECK`);
иначе `PROCESSING` — у ЮЛ есть файл `UPLOADED`/`PROCESSING`; иначе `ERROR` — последний файл ЮЛ в `ERROR`; иначе
`MISSING`. `missing_quarters` — пропущенные кварталы при частичном покрытии квартальными ОСВ. Для ОСВ правило закрывает
только документ с `osv_account_code = account` правила (OQ-04). Ячейки `PROCESSING` по всем недостающим столбцам,
пока файл не смаплен, — осознанная неточность (#6). Те же ячейки — в карточке версии у каждого ЮЛ.
### D-14. Экспорт
`GET …/osv/export` → `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition:
attachment; filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx`. Лист «Сводная ОСВ»:
Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт, Обороты Дт,
Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы — числовые
ячейки `#,##0.00`, рубли. Лист «Пробелы»: Компания ГК, Счёт, Нет ОСВ за кварталы; при пустом списке — одна ячейка
«пробелов нет». Фильтры как у `GET …/osv`, без пагинации; `audit_event(EXPORT, section EXPORT, object =
consolidation_version, after = {filters, rows})`. Строится в памяти (`BytesIO`), S3 не используется.
### D-15. Создание консолидации
`POST /consolidations {company_group_id, related_group_id, year, quarter}`: ГК видима (404), ГСК принадлежит ГК и
`kind = gsk` (422 `RELATED_GROUP_MISMATCH`), `year` 2000..2100; `consolidation(reporting_date)` по правилу банка
(Q1 → 01.04.Y, Q2 → 01.07.Y, Q3 → 01.10.Y, Q4 → 01.01.Y+1; ASSUMPTION(OQ-40)); версия №1: `INITIATED`, `CREATE`,
`BASIC`, `goodwill_enabled = false` (OQ-27), `initiator_id` = пользователь, `initiator_office_id` = его
`regional_office_id`, `is_in_work = true`; члены — текущие члены ГСК (`current_members`), `is_head_entity = false`
(гудвилл — Срез 6); меньше двух → 422 `PERIMETER_TOO_SMALL`, ничего не создаётся. ГСК не сохраняется
(`related_group_id` NULL до верификации по DBML). Первая запись истории (`from_status NULL`), подхват документов (D-5),
`audit_event(CONSOLIDATION_CREATE)`. Блокировка на создателя не ставится (макет: «Взять в работу» — отдельное действие).
### D-16. Схемы ответов
`ConsolidationPeriodOut {consolidation_id, year, quarter, reporting_date, versions[]}`;
`VersionBrief {id, version_no, status, scenario, approach, lock, created_at, members_count}`;
`LockOut {locked_by, locked_by_name, locked_at, taken_over_from, takeover_reason}`;
`VersionCard = VersionBrief + {consolidation, company_group {id, name}, initiator, initiator_office, goodwill_enabled,
is_in_work, previous_version_id, members[] (ЮЛ + ячейки чек-листа), history[]}`;
`ConsolidatedRow {entry_ids[], legal_entity_id, legal_entity_name, legal_entity_inn, account_code, account_root,
period, is_cumulative, row_number, counterparty_name, counterparty_inn, counterparty_id, counterparty_entity_name,
counterparty_type, match_kind, match_stored, opening_debit … closing_credit (Decimal → строка JSON), warnings[]}`;
`OsvSummary {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[] {legal_entity_id,
legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/osv`: `legal_entity_id`, `account_root`,
`counterparty_type`, `unmapped`, `match_kind` (список), `q`; `limit ≤ 2000` (по умолчанию 500), `offset`. Порядок:
имя ЮЛ, `account_code`, `row_number`, `entry_ids[0]`.
## Risks / Trade-offs
- Свод в памяти (D-1): большая группа × много счетов → секунды на запрос; порог — OQ-121.
- `PROCESSING` в чек-листе по всем столбцам, пока файл не смаплен (D-13): шум, но честный.
- Backfill `match_kind` (D-4) приближённый; на тестовых данных ручных сопоставлений до Среза 4 нет.
- Общие строки `osv_entry` у нескольких версий: ручное сопоставление в одной версии меняет строку для всех — это
и есть «хранимое ручное решение» DECISION-5, `before_value` аудита хранит прежнее.
## Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-06 | Две строки с одним ключом (счёт, имя, ИНН) внутри одного квартала суммируются при склейке | иначе склейка неоднозначна; в нарастающих документах такие строки остаются раздельными | сумма вместо двух строк в редком случае дубля имени; `entry_ids` сохраняет оба источника |
| 2 | 2026-10-06 | Маппинг без `uploaded_by` переход версии пропускает с логом | `changed_by NOT NULL`; файлы без автора — только seed/тесты | версия застревает в `DOCUMENTS_PROCESSING`; подтверждение документа всё равно переведёт в `DATA_CORRECTION` |
| 3 | 2026-10-06 | `CANCELLED` снимает активную блокировку | аннулированную версию никто не редактирует | нет |
| 4 | 2026-10-06 | Возврат в `INITIATED` сохраняет `consolidation_document` | периметр правится отдельно и сам отвязывает документы удалённых ЮЛ | при смене комплекта пользователь не видит «пустой» версии; привязки переподхватятся при повторном подтверждении |
| 5 | 2026-10-06 | `scope=all` требует `q` ≥ 3 символов | весь справочник без policy не отдаём списком | одна лишняя 422 для фронта |
| 6 | 2026-10-06 | Ячейка чек-листа `PROCESSING` для всех недостающих столбцов ЮЛ, пока файл в обработке | до маппинга счёт файла неизвестен | временный шум в чек-листе |
| 7 | 2026-10-06 | Алиасы при ручном сопоставлении пишут `LEGAL_ENTITY_CHANGE` в дополнение к `OSV_COUNTERPARTY_MAP` | журнал справочника (Срез 1) остаётся полным | два события на одно действие |
| 8 | 2026-10-06 | `counterparty_id` строки свода при расхождении между кварталами — из последнего квартала, расхождение в `warnings` | нужно одно значение на строку; автоприменение выравнивает | аналитик видит предупреждение, не ошибку |
| 9 | 2026-10-06 | `GET …/osv` и экспорт не требуют блокировки, только `CONSOLIDATION_VIEW` | чтение | нет |
| 10 | 2026-10-06 | ББ/ОФР (`is_cumulative` NULL) считаются нарастающими при привязке | формы всегда за период с начала года | нет |
| 11 | 2026-10-06 | R-1: ключ замены/выбора «самого позднего `confirmed_at`» при привязке = `(document_type, osv_account_code, period_quarter, cumulative)`, `cumulative = (is_cumulative is not False)` | тройка без признака нарастающего вытесняла бы нарастающий Q3 квартальным Q3 одной пары (ЮЛ, счёт), а сценарий «Нарастающий рядом с квартальными» требует обоих | если владелец хотел «один документ на квартал» — нарастающий и квартальный вытесняли бы друг друга; меняется только ключ в `bind_confirmed`/`on_document_confirmed` |
| 12 | 2026-10-06 | R-2: `lock_versions_for_entity` блокирует `FOR UPDATE ORDER BY id` надмножество версий «в работе» (ЮЛ — член ИЛИ `company_group_id` совпадает), затем отдельным запросом возвращает только версии, где ЮЛ — член; создание версии берёт `pg_advisory_xact_lock` по ГК | иначе confirm ∥ `PUT …/members` теряет привязку (не видит незакоммиченного члена), а автозамена ∥ подхват оставляет привязку заменённого документа | тихо потерянная привязка документа (редко, чинится повторным добавлением ЮЛ в периметр) |
| 13 | 2026-10-06 | R-3: `lock_document`, `lock_file_documents` (и `lock_file` при участии в тех же циклах) берут `FOR NO KEY UPDATE` (`with_for_update(key_share=True)`) | вставка `consolidation_document` берёт `FOR KEY SHARE` на документ, а `FOR UPDATE` подтверждения конфликтует с ней: confirm ∥ правка периметра/подхват взаимоблокируются | нулевая: для остальных путей блокировка писателей не меняется, слабее только по FK-ссылкам |
| 14 | 2026-10-06 | R-4: `Resolution(entity_id, kind)` определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` импортирует и реэкспортирует | `domain/consolidated_osv.py` (mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`, домен не импортирует репозитории | нулевая (перенос определения) |
| 15 | 2026-10-06 | R-5: Task 2 создаёт `domain/checklist.py` с `EntityFilesState(has_in_progress, last_file_status)` и `entity_files_state(session, entity_ids)`; `unconfirmed_documents(...)` и `build` добавляет Task 8 | план описывал `entity_files_state` и в Task 2, и в Task 8; `build` получает и `files_state`, и `unbound` | нулевая (разнесение по задачам) |
| 16 | 2026-10-06 | `osv_entry.counterparty_match_kind` — последняя колонка таблицы (в ORM и DBML), а не сразу после `counterparty_id` | `ADD COLUMN` в миграции 0004 добавляет колонку в конец, а `test_migrated_schema_equals_orm_create_all` сравнивает `ordinal_position`; пересоздание таблицы с 200 тыс. строк ради порядка колонок не оправдано | косметика: порядок колонок в `SELECT *`/диаграмме; на поведение не влияет |
| 17 | 2026-10-06 | R-8: коды 404 `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` реализованы как текст сообщения при фиксированном коде `NOT_FOUND` | конвенция Срезов 1–2c: `NotFoundError` с фиксированным кодом, как `COMPANY_GROUP_NOT_FOUND` | если нужны отдельные коды — меняется конструктор ошибки и тесты |
| 18 | 2026-10-06 | R-9: `in_work_only` убирает период без оставшихся версий; невидимая/несуществующая ГК в списке → 404; неизвестная/невидимая ГСК → 404 (сообщение `RELATED_GROUP_NOT_FOUND`), видимая ГСК чужой ГК или `kind = gsz` → 422 `RELATED_GROUP_MISMATCH` | естественное продолжение policy «невидимое = несуществующее» (404); несоответствие видимой ГСК — ошибка запроса | расхождение с ожиданием фронта по пустому списку |
| 19 | 2026-10-06 | `takeover_reason` пустой или из одних пробелов считается отсутствующим: при чужой блокировке — 409 `LOCKED_BY_OTHER`, а не 422; при свободной версии причина не сохраняется (`taken_over_from` NULL, обычное взятие 201) | спека требует «непустую» причину только для перехвата; при свободной версии перехватывать не у кого | фронт не получит 422 на пустую причину; в БД нет «причины» у обычного взятия |
| 20 | 2026-10-06 | Комментарий `POST …/status` обрезается по краям; из одних пробелов — пустой (`COMMENT_REQUIRED` для `CANCELLED`); в `cancelled_reason` и историю пишется обрезанный текст | «пустой комментарий» по спеке включает пробельный; обрезка не меняет смысла | косметика: ведущие/хвостовые пробелы комментария теряются |
| 21 | 2026-10-06 | `NOT_LOCK_OWNER` отдаёт в `detail` `locked_by` и `locked_at` владельца (по аналогии с `LOCKED_BY_OTHER`) | спека полей не перечисляет; фронту нужно показать, у кого блокировка | нулевая (лишние поля ответа) |
| 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) |
| 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ |
| 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая |
| 25 | 2026-10-06 | `PUT …/members` с составом, равным текущему, отвечает 200 с карточкой, ничего не пишет (ни членов, ни `updated_at`) и НЕ пишет `MEMBERSHIP_CHANGE` | событие «периметр изменён» без изменения вводит в заблуждение и засоряет журнал; идемпотентный повтор запроса (двойной клик, ретрай) безопасен | в журнале нет следа повторного сохранения того же состава (оно ничего не меняло) |
| 26 | 2026-10-06 | `ENTITY_NOT_IN_GROUP` покрывает три случая: ЮЛ другой ГК, ЮЛ без ГК и несуществующий id; `legal_entity_ids` — в порядке запроса. Проверка идёт ДО проверки размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`, не `PERIMETER_TOO_SMALL`); дубли схлопываются ДО подсчёта размера (`[a, a]` → `PERIMETER_TOO_SMALL`) | спека: «все ЮЛ SHALL принадлежать ГК» — несуществующее ЮЛ ей не принадлежит; существование отдельным кодом не различаем, чтобы не раскрывать чужие id (как 404 вместо 403) | клиент не отличает «нет такого ЮЛ» от «ЮЛ чужой ГК» |
| 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая |
| 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) |
| 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) |
| 30 | 2026-10-06 | R-2, гонка «создание версии ∥ подтверждение/автозамена» закрыта `pg_advisory_xact_lock(hashtextextended('consolidation-group:<ГК>', 0))` (`repositories.consolidations.lock_group_bindings`): `ConsolidationService.create` берёт его сразу после проверки видимости ГК (до чтения ГСК, проверки дубля периода и подхвата), `lock_versions_for_entity` — первым шагом, если у ЮЛ есть ГК (ЮЛ без ГК не может быть членом версии). Полный порядок: файл → документ → advisory ГК → версии; автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; `PUT …/members` advisory не берёт. Тесты на двух сессиях в обе стороны (`test_create_waits_for_confirm_…`, `test_confirm_waits_for_create_…`), мутанты без advisory в любой из сторон их роняют | новая версия невидима до commit, `FOR UPDATE` версий её не ловит; advisory по ГК — минимальная сериализация, не трогающая правку периметра | создание консолидаций одной ГК (любых периодов) сериализуется с подтверждениями и автозаменами документов ЮЛ этой ГК — ожидание на время короткой транзакции; параллельные создания одного периода теперь получают 409 `CONSOLIDATION_EXISTS` проверкой, а не UNIQUE. Все подтверждения (и автозамены) документов ЮЛ одной ГК теперь сериализуются на advisory-блокировке этой ГК, даже если у ГК нет ни одной версии «в работе» (lock берётся до поиска версий) |
| 31 | 2026-10-06 | R-3: на `FOR NO KEY UPDATE` переведены только `lock_document` и `lock_file_documents`; `lock_file` (и `_lock_file` маппинга) остаётся `FOR UPDATE` | на `stored_file` нет FK из таблиц консолидации, в циклы привязки файл не входит; удаление файла (`DELETE stored_file`) под `FOR NO KEY UPDATE` повышало бы блокировку уже после захвата и могло бы встречно ждать `KEY SHARE` вставки задания | нулевая; если файл войдёт в цикл — та же правка `key_share=True` |
| 32 | 2026-10-06 | R-3 в текущих потоках не воспроизводится через API: подхват привязывает только документы, `CONFIRMED` в зафиксированном снимке, а подтверждение держит документ, который ещё не `CONFIRMED` (иначе сразу 409). Тест `test_document_lock_does_not_block_binding_insert` проверяет сам цикл блокировок на примитивах (`lock_document` + ожидание версии ∥ `lock_version` + вставка привязки): с `FOR UPDATE` — `DeadlockDetectedError`, с `FOR NO KEY UPDATE` — проходит. API-тесты «подтверждение ∥ `PUT …/members`» в обе стороны проверяют R-2 (ожидание и непотерянную привязку) | диспатч требовал мутант-проверку R-3; на уровне API цикла нет, но страховка дешёвая и защищает будущие пути (Task 7/11) | нулевая |
| 33 | 2026-10-06 | Автозамена снимает привязки ВСЕХ документов заменённого файла, а не только совпавших с новым по ключу автозамены | spec primary-files: «привязки документов заменённых файлов»; файл `SUPERSEDED` целиком, а подхват берёт документы лишь незаменённых файлов — оставшаяся привязка была бы «висячей» | документ заменённого файла, не повторённый в новом файле, выпадает из версии (пробел в чек-листе) — так и задумано спекой |
| 34 | 2026-10-06 | `FILE_REPLACE.after_value.unbound_version_ids` — версии, где сняты привязки документов ИМЕННО этого заменённого файла (по возрастанию `id`; пустой список, если не было); `on_documents_superseded` вызывается по одному разу на заменённый файл | событие пишется на каждый заменённый файл; общий список версий по всей автозамене приписал бы файлу чужие отвязки. Повторные вызовы берут ту же advisory ГК и те же версии (уже удержаны) — порядок блокировок не меняется | нулевая |
| 35 | 2026-10-06 | Выбор «самого позднего `confirmed_at`» при равенстве — по наибольшему `id`; `confirmed_at` NULL у `CONFIRMED` (только тестовые данные) считается самым ранним. `bind_confirmed` заменяет привязанный документ того же ключа победителем и не трогает уже привязанного победителя (повторный подхват — 0 новых привязок) | детерминизм; спека порядок при равенстве не задаёт | при одинаковом `confirmed_at` привязан «случайный по id» из двух — равноценных — документов |
| 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции |
| 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) |
| 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет |
| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически |
| 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга |
| 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл |
| 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего |
| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе |