Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
273 lines
86 KiB
Markdown
273 lines
86 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` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе |
|
||
| 44 | 2026-10-06 | Статус ячейки — одно определение: `domain.checklist.CellStatus` (StrEnum); `ChecklistCellOut.status` в схеме API использует его, `Literal` `ChecklistCellStatus` удалён (T2.8) | два расходящихся определения — источник тихого рассинхрона домена и контракта | нулевая: OpenAPI-enum тот же набор значений |
|
||
| 45 | 2026-10-06 | Проверка «подходящести» неподтверждённых документов (`fits`: член периметра, год, квартал) выполняется в сервисе (`_checklist_cells`), в домен `build` приходят уже подходящие; домен сам сверяет только ЮЛ, тип, счёт и статус (`RECOGNIZED`/`NEEDS_CHECK`) | `fits` живёт в `services.consolidation_documents` (домен не импортирует сервисы и ORM); дублировать предикат в домене — риск расхождения с привязкой | если `fits` изменится — меняется и чек-лист, что желательно; вне `fits` домен кандидатов не отсеивает |
|
||
| 46 | 2026-10-06 | Выбор источников ОСВ в чек-листе — локальная функция `_osv_coverage` (нарастающий за квартал версии, иначе квартальные Q1..Qn все; нарастающий приоритетнее; пробел → `missing_quarters` + документы имеющихся кварталов), помечена `# Task 9 заменит на consolidation_periods.select_sources`; при полном отсутствии документов `missing_quarters = []` (перечисляются только при частичном покрытии, как в спеке) | `select_sources` появится в Task 9; план прямо разрешает локальную проверку до него | до Task 9 два места с правилом выбора; Task 9 удаляет локальное |
|
||
| 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) |
|
||
| 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая |
|
||
| 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая |
|
||
| 50 | 2026-10-06 | Склейка квартальных строк всегда даёт `Decimal`: сумма `None` и отсутствующая строка — ноль, в том числе в `opening_*` из Q1 и `closing_*` из Qn; «как есть» (с `None`) отдаются только строки нарастающего документа. Версия за Q1 с квартальным Q1 идёт путём склейки (`is_cumulative = false`, дубли ключа суммируются) | пустая ячейка ОСВ означает ноль; одно правило для всех склеенных строк; признак строки отражает тип документа | в склеенной строке пустая ячейка файла показывается как `0.00`, а не пусто |
|
||
| 51 | 2026-10-06 | «Как в файле» (`account_code`, `account_root`, `counterparty_name/inn`) и `row_number` склеенной строки — из последнего квартала, где ключ есть (минимальный номер строки в нём, пустой — в конце, затем `id`); если ключа нет в Qn (контрагент только в Q2) — из последнего квартала с ним | спека задаёт `row_number` по последнему кварталу, но не говорит про ключ, которого в нём нет; написание имени/ИНН внутри ключа может отличаться пробелами | порядок такой строки в своде и показанное написание имени |
|
||
| 52 | 2026-10-06 | `counterparty_id` склейки — первая сопоставленная строка самого позднего квартала, где сопоставленные есть; `match_kind` и `match_stored = true` — от неё же; в `warnings` (по строке на источник: «Qn: строка <id> сопоставлена с <id>, в своде — <id>») попадают только строки с ДРУГИМ непустым `counterparty_id`, пустые расхождением не считаются. Дорезолв (`Resolver.resolve(inn, name)`) вызывается только когда сопоставленных нет, по ИНН/имени «как в файле» той же строки; не найден → `match_stored = false`, `match_kind = null`, `EXTERNAL` | D-3 и решение №8; несопоставленная строка рядом с сопоставленной — обычное состояние до автоприменения | текст предупреждений — не контракт, фронт показывает как есть |
|
||
| 53 | 2026-10-06 | Порядок счетов в своде и пробелах — натуральный (`account_sort_key`, перенесён из чек-листа в `domain/osv_mapping.py`: `9 < 60 < 60.2 < 60.10`), при равенстве — код строкой; внутри имени ЮЛ — ещё `legal_entity_id` (тёзки не перемешиваются) | одно определение порядка счетов для чек-листа и свода (№48); спека говорит «account_code», текстовая сортировка ставит `60.10` раньше `60.2` | порядок строк в UI/экспорте при необычных кодах |
|
||
| 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка |
|
||
| 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех |
|
||
| 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись |
|
||
| 57 | 2026-10-06 | `OsvFilters` (фильтры) и `OsvPageParams` (`limit` ≤ 2000, по умолчанию 500; `offset` ≥ 0) — отдельные модели; для маршрута `GET …/osv` они сливаются в `OsvQuery(OsvFilters, OsvPageParams)`, потому что FastAPI принимает одну query-модель на маршрут (две модели дают 422 «Field required» по `filters`/`page`); сервису `OsvQuery` передаётся и как фильтры, и как пагинация, экспорт (Task 12) берёт только `OsvFilters` | закрывает T2.7; проверено падением теста до слияния | при ошибке — query-параметры маршрута придётся перегруппировать; контракт API (плоские параметры) не меняется |
|
||
| 58 | 2026-10-06 | Суммы `ConsolidatedRowOut` приводятся валидатором к двум знакам (`quantize(Decimal("0.01"))`): `"0.00"`/`"30.00"`, не `"0"`/`"30"` | склейка даёт `Decimal("0")` для отсутствующей в квартале строки, а спека и макет показывают суммы `numeric(18,2)` с двумя знаками (`"100.00"`); значение не меняется (в данных знаков больше двух нет) | при ошибке — лишние/недостающие нули в JSON, суммы остаются точными |
|
||
| 59 | 2026-10-06 | `account_sort_key` считает числовым сегмент только из ASCII-цифр (`[0-9]+`), остальные — текстом после числовых; `isdigit()` пропускал `"6²"`/`"①"`, на которых `int()` падает (T9.a) | код счёта приходит из файла; исключение роняло бы всю страницу свода и чек-лист (500) | при ошибке — экзотический код счёта сортируется как текст, а не по значению; падения нет |
|
||
| 60 | 2026-10-06 | Идентификаторы кандидатов дорезолва `_build` собирает заранее: `CounterpartyIndex.resolve` (словари в памяти, без БД) по каждой строке без `counterparty_id`, плюс хранимые `counterparty_id`; один `SELECT legal_entity … WHERE id IN (…)` без policy; члены периметра берутся из `members` и в запрос не входят. Запросов к БД на `GET …/osv` — константа (8 SELECT на 5 400 исходных строк), без N+1; страница режется в памяти после `domain.build` и `apply_filter` | свод — чистая функция версии (D-1), срез и фильтры по `counterparty_type`/`unmapped` зависят от дорезолва, поэтому строить нужно весь свод | при ошибке — `IN` с очень большим числом контрагентов упрётся в лимит параметров asyncpg (32 767); число различных контрагентов версии на порядки меньше |
|
||
| 61 | 2026-10-06 | `ConsolidatedOsvService` только читает: без блокировок и аудита, версия любого статуса (включая `CANCELLED`) читается; `documents` для `build` — привязанные документы `document_type = OSV` (`bound_documents`), `rows` — ровно результат `load_osv_rows` (`COUNTERPARTY`, `BU`/`SUM`): домен `group_type`/`data_type` не фильтрует (замечание ревью Task 9) | спека «Страница свода и сводка»; DECISION-5 | при ошибке — в свод попали бы строки `ACCOUNT`/`NU` и задвоили бы суммы |
|
||
| 62 | 2026-10-06 | `ENTRY_NOT_FOUND` — 404 с кодом `NOT_FOUND` и текстом «строка ОСВ не найдена в версии» (как `VERSION_NOT_FOUND`: `NotFoundError` несёт только сообщение). «Строка версии» — строка свода: `COUNTERPARTY` с `data_type ∈ {BU, SUM}` в ОСВ-документе, привязанном к версии (`entry_in_version`); строки `NU`/`ACCOUNT` привязанного документа дают тот же 404 | спека требует 404 для строки непривязанного документа; сопоставлять строку, которой нет в своде и которую маппер не резолвит (R-3 Среза 2c), незачем | клиент отличает «нет строки» от «нет версии» только по тексту |
|
||
| 63 | 2026-10-06 | Автоприменение — только строкам свода версии (`COUNTERPARTY`, `BU`/`SUM` привязанных ОСВ-документов); имя сравнивается как `strip()` Python ↔ SQL `btrim(counterparty_name, PY_WHITESPACE)` (полный набор `str.isspace`, включая NBSP — одно определение со склейкой D-2); пустое имя (NULL/пробелы) — автоприменения нет, связывается только сама строка | спека: «строкам ОСВ-документов, привязанных к версии, с тем же `counterparty_name.strip()`»; строки `NU` в свод не входят | строки `NU` того же контрагента остаются без `counterparty_id` (как и после маппинга) |
|
||
| 64 | 2026-10-06 | Перевязка уже связанной строки автоприменением не трогает строки, связанные прежде (в том числе `PROPAGATED` от прошлого ошибочного сопоставления) — только `counterparty_id IS NULL`; `before_value` хранит прежние `counterparty_id`/`match_kind` | спека: автоприменение — «пустым `counterparty_id`» | ошибочно распространённое сопоставление исправляется по строке (или повтором после отвязки — вне Среза 4) |
|
||
| 65 | 2026-10-06 | Порядок блокировок сопоставления: версия `FOR UPDATE` (guard) → advisory по нормализованному алиасу (`lock_alias_key`, только при непустом) → строки алиасов `FOR UPDATE` → строки `osv_entry` с тем же именем и сама строка `FOR NO KEY UPDATE ORDER BY id` (`lock_entries_same_name`, `populate_existing`). Гонка реальна только между сопоставлениями в РАЗНЫХ версиях с общими документами (OQ-123): маппер переписывает строки лишь своего файла в `PROCESSING`, удаление файла с `CONFIRMED` запрещено, автозамена только отвязывает, привязки версии меняются под её `FOR UPDATE`. Упорядоченная блокировка исключает взаимоблокировку (A связывает r1 и распространяет на r2, B — наоборот), а свежее чтение — перетирание чужого результата и устаревший `before` (тест на двух сессиях) | строки ОСВ общие для версий; без блокировки второе сопоставление перезаписало бы `PROPAGATED` первого и записало бы в аудит устаревшее `before` | при ошибке — взаимоблокировка (500) или потерянное сопоставление |
|
||
| 66 | 2026-10-06 | `lock_alias_key` берёт и `aliases.add` справочника (Среза 1): глобальная уникальность `normalized_alias` в БД не обеспечена (индекс — `(ЮЛ, normalized_alias)`), без общей блокировки добавление в справочнике ∥ сопоставление закрепили бы алиас за двумя ЮЛ. Нарушение индекса `(ЮЛ, normalized_alias)` при вставке алиаса в сопоставлении ловится под SAVEPOINT как «алиас у ЮЛ уже есть» (`alias_created = false`), сопоставление продолжается | последний рубеж на случай писателя вне этих путей; README «Гонки уникальных ограничений» обновлён | одна лишняя advisory-блокировка на добавление алиаса |
|
||
| 67 | 2026-10-06 | Алиас сопоставления хранит `counterparty_name.strip()`; `LEGAL_ENTITY_CHANGE` по созданному/удалённому алиасу — тот же payload, что в Срезе 1 (`legal_entity_id`, `alias`, `normalized_alias`; функция `aliases.alias_payload`), раздел `DIRECTORIES`, `object_type = legal_entity_alias`, `object_id` = алиас, и дополнительно `consolidation_version_id` версии | audit-trail: «как в Срезе 1»; ссылка на версию связывает событие справочника с причиной | нет (поле nullable, в Срезе 1 пустое) |
|
||
| 68 | 2026-10-06 | `alias_moved_from` (ответ и `after_value` `OSV_COUNTERPARTY_MAP`) — один `legal_entity_id`: первый владелец по `(created_at, id)`; при нескольких владельцах (возможны только из данных до общей блокировки) удаляются все их алиасы, каждый со своим `LEGAL_ENTITY_CHANGE`; `ALIAS_TAKEN.owners` — все владельцы в том же порядке | схема `MapCounterpartyOut.alias_moved_from: UUID \| None` (Task 3) | остальные прежние владельцы видны только в `LEGAL_ENTITY_CHANGE` |
|
||
| 69 | 2026-10-06 | `MapCounterpartyOut.row` — `ConsolidatedRowOut \| null`: строка привязанного документа, которая в свод не попадает (пара с пробелом, квартальный документ при нарастающем), сопоставляется, но строки свода для ответа нет — `row = null` | спека разрешает сопоставлять любую строку привязанного ОСВ-документа; выдумывать строку свода из одной исходной нельзя | фронт обрабатывает `null` (из UI такая строка не выбирается — её нет в своде) |
|
||
| 70 | 2026-10-06 | Кандидаты: `entry_id` не строки свода версии (чужой, непривязанный, несуществующий) или имя с пустой нормализацией — рекомендаций нет, не 404; `recommended` ищется по `CounterpartyIndex.by_normalized` (имена и алиасы всего справочника; загружается только при `entry_id`); `q` обрезается по краям (`scope=all`: пустой или короче 3 — 422 `QUERY_TOO_SHORT`; `max_length` 500); ИНН — префикс без пробелов внутри `q`; алиас — подстрока `legal_entity_alias.alias`; `scope=group` = ГК консолидации ∪ периметр версии; признак — `domain.classify` | D-11 говорит «если передан»; нормализация — функция Python, в SQL её нет, а индекс — тот же, что у резолва свода | лишняя загрузка справочника на запрос с `entry_id` (как у `GET …/osv`) |
|
||
| 71 | 2026-10-06 | Экспорт: лист «Пробелы» строится по своду целиком (`Consolidated.gaps`) и от фильтров строк не зависит; при `unmapped=true` без совпадений лист «Сводная ОСВ» — только заголовок, «Пробелы» заполнен, `audit.rows = 0` (Review Focus 5) | D-14 и спека описывают лист пробелов отдельно от фильтров; пробел — свойство версии, а не строк, и фильтр не должен прятать неполноту данных | файл с фильтром показывает пробелы, не относящиеся к выбранным строкам; читателю это видно по листу |
|
||
| 72 | 2026-10-06 | Колонки листа: «Контрагент»/«ИНН контрагента» — как в файле ОСВ (`counterparty_name`/`counterparty_inn`), «Сопоставлено с» — имя ЮЛ справочника (`counterparty_entity_name`, пусто без сопоставления), «Тип контрагента» — код `GSK`/`GK`/`EXTERNAL`, «Способ сопоставления» — код `match_kind` (пусто без него); пустые значения — пустые ячейки | D-14 перечисляет колонки без значений; коды совпадают с API, и выгрузка сверяется со страницей свода построчно | если банк захочет русские подписи типа — правка одного словаря в `osv_export` |
|
||
| 73 | 2026-10-06 | Суммы пишутся `Decimal` напрямую; `openpyxl` сериализует число в XML через `%.16g` (формат xlsx — IEEE-754 double): значения до 10^13 с копейками читаются обратно точно, выше ~10^13 младшая копейка может потеряться (`96729051052621.71` → `…621.7`; ≈1,3 % случайных значений в 10^13–10^14), `9999999999999999.99` станет `1e+16`; защит в коде нет (порог уточнён ревью T12.3) | суммы ОСВ в рублях на практике ниже 10^13, а сам Excel держит 15 значащих цифр; тест сверяет `Decimal(str(cell.value))` с API | сумма от 10^13 руб. в выгрузке может потерять копейку, в БД и API — точная |
|
||
| 74 | 2026-10-06 | Имя файла: `svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx`, имя ГК берётся как есть и целиком кодируется `quote(safe="")` (пробел, кавычки, `;`, `/`, `'`, переводы строк, кириллица) — заголовок `Content-Disposition` чистый ASCII, `filename="svod-osv.xlsx"` фиксирован; квартал — `Q1..Q4` | D-14: `<год>-<квартал>`; фиксированное ASCII-имя защищает от инъекции в заголовок | клиент без поддержки `filename*` сохранит файл как `svod-osv.xlsx` |
|
||
| 75 | 2026-10-06 | `build_workbook(c, rows, *, entity_names)`: параметр `entity_names` сохранён по плану как переопределение отображаемых имён ЮЛ по id, без записи в словаре — имя из строки/пробела; сервис передаёт `{}`, так как строки и пробелы уже несут имена | интерфейс зафиксирован планом; убирать параметр — менять контракт задачи | параметр сейчас не используется сервисом; кандидат на удаление при ревью (снято решением #78) |
|
||
| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить; `model_dump(mode="json", exclude_none=True)` отличается от буквального `model_dump` плана (M-5): UUID и enum должны быть JSON-сериализуемы для `audit_event` |
|
||
| 77 | 2026-10-06 | R-12: все текстовые ячейки экспорта (компания, ИНН, счёт, период, контрагент, ИНН контрагента, «Сопоставлено с», тип, способ, лист «Пробелы») проходят `_put_text`: запрещённые XML-символы (`ILLEGAL_CHARACTERS_RE`) вырезаются, тип ячейки принудительно `"s"`, поэтому `=`, `+`, `-`, `@` в начале не становятся формулой, а текст читается обратно без изменений | файлы внешние, аналитики открывают выгрузку в Excel: без этого `=HYPERLINK(...)` из чужого файла выполнился бы в их книге, а управляющий символ давал `IllegalCharacterError` → 500 | текст ячейки с ведущим `=` больше не вычисляется — это желаемо; вырезанный управляющий символ из выгрузки пропадает |
|
||
| 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр |
|
||
| 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` |
|
||
| 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из прежней формулировки spec consolidated-osv «Сопоставление из плана среза» нарушается дважды: квартальные записи склеиваются в одну строку свода, а записи в парах-пробелах (счета 66/76 РБ) дают `propagated`, но строк свода не дают; верно оно только когда записи не склеены и не лежат в пробелах (нарастающие документы без пробелов) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec исправлена (fix round 1, M1); код не менялся |
|
||
| 81 | 2026-10-06 | R-14 + R-14a: ОСВ за `Q1` (`period_quarter = Q1`) равнозначна и нарастающей, и квартальной. `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `select_sources` (для версии `Q2+` ОСВ за `Q1` — квартальный источник `Q1`; нарастающая за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающая «как есть», квартальная — склейка, №50), в адаптере чек-листа и — только для `document_type = OSV` — в `consolidation_documents._cumulative`, то есть в `fits` (ОСВ за `Q1` подходит любой версии года при любом флаге) и в ключе замены `_key` (нарастающая и квартальная ОСВ за `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`). R-14a: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4`: для них прежнее правило D-5 (NULL → нарастающий → только квартал версии), ключ замены не меняется | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающая и квартальная ОСВ за `Q1` одной пары вытесняют друг друга при привязке; если и ББ/ОФР за `Q1` должны подхватываться версиями `Q2`–`Q4` — снять условие `document_type = OSV` в `_cumulative` |
|
||
| 82 | 2026-10-06 | Task 14 (документы): открытые вопросы владельцу заведены в `07-open-questions.md` как OQ-124…OQ-129 (после OQ-118…123 из docs-proposal): Q1-эквивалентность ОСВ (R-14/R-14a), склейка Q1 версии Q1 (№50), область ручного сопоставления (R-11), нечёткая рекомендация кандидата (O-3), неоднозначность дорезолва при тёзке (T11.9), формулировка сценария «1 + propagated» (R-15) | список вопросов ждёт подтверждения владельца; допущения уже реализованы в коде, поэтому вопросы — «допущение принято», а не «открыт» | если владелец решит иначе, меняется код и docs в соответствующем месте (указано в каждом OQ) |
|
||
| 83 | 2026-10-06 | OQ-03 и OQ-19 в реестре помечены закрытыми решением владельца 06.10.2026, OQ-40 — «допущение принято и реализовано» (не «закрыт»: правило банка не подтверждено аналитикой), OQ-110 — закрыт только для Среза 4 (остаток FK — Срезы 3 и 7) | docs-proposal велит закрыть OQ-03/19/110/40; формулировка по OQ-40 следует правилу CLAUDE.md «агент не закрывает вопросы сам» (допущение, а не решение) | реестр вопросов формально неточен, если владелец считает OQ-40 решённым |
|
||
| 84 | 2026-10-06 | `dev/qa-guide.md`: новый раздел 7 «Консолидация через API (Срез 4)», прежние разделы 7–10 сдвинуты на 8–11, внутренние ссылки «раздел 8» → «раздел 9» | docs-proposal называет раздел §7; внешних ссылок на номера разделов гайда в репозиториях нет (ссылаются по названию) | сторонние закладки на «раздел 8» (Сброс стенда) указывают на «Известные ограничения» |
|
||
| 85 | 2026-10-06 | Контракт API (`../docs/dev/api-contract-slice-4.md`) описывает доменные 422-коды (`COMMENT_REQUIRED`, `PERIMETER_TOO_SMALL` и др.) вручную: в OpenAPI они перечислены только свободным текстом в `description` ответа 422 (например, `CREATE_ERRORS` в `periods.py`: «RELATED_GROUP_MISMATCH, PERIMETER_TOO_SMALL или ошибка валидации тела»); машиночитаемой схемы и кодов нет (общий 422 `HTTPValidationError`; у 404/409 — `ErrorDetail`) | менять объявления маршрутов в Task 14 нельзя (документная задача); OpenAPI проверен `test_openapi_routes` — у всех маршрутов консолидации есть `operationId` и `responses` | фронт не получит доменные 422-коды из сгенерированного клиента, пока responses не расширены (follow-up) |
|