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
231 lines
49 KiB
Markdown
231 lines
49 KiB
Markdown
# 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` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе |
|