- consolidation_documents: чистая fits (D-5), bind_confirmed (подхват, самый поздний confirmed_at на ключ R-1), unbind_entity, on_document_confirmed (привязка ко всем подходящим версиям «в работе», переход в DATA_CORRECTION через transition), on_documents_superseded (отвязка документов заменённых файлов). - DocumentService.confirm вызывает хук привязки; supersede_previous снимает привязки и пишет FILE_REPLACE.after_value.unbound_version_ids; create и PUT …/members подхватывают по-настоящему. - R-2: advisory-блокировка привязок ГК (lock_group_bindings) в create и lock_versions_for_entity закрывает гонку «создание версии ∥ подтверждение/автозамена». - R-3: lock_document и lock_file_documents — FOR NO KEY UPDATE (совместимо с FOR KEY SHARE вставки привязки). - Тесты: таблица fits, сценарии documents-api и primary-files, две версии одного документа, гонки на двух сессиях (confirm ∥ members в обе стороны, create ∥ confirm в обе стороны, цикл блокировок документа и версии). - design.md: решения №30–№36; plan и tasks.md (3.1) отмечены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
43 KiB
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 |
| 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 той же транзакции |