Andrey Epifancev 36bc966bf6 feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене
- 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
2026-10-06 04:57:15 +04:00

43 KiB
Raw Blame History

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 той же транзакции