87 KiB
Raw Blame History

Design: Срез 4 — консолидация и сводная ОСВ

Context

Обследование 06.10.2026 (.wt/slice-4): модели шести таблиц консолидации и required_document_rule есть (1:1 с DBML v5.15), сервисов/репозиториев/эндпоинтов/seed консолидаций нет; CONSOLIDATION_VIEW/EDIT выданы ролям, но не используются; domain/policy.py знает только видимость справочников; DocumentService.confirm не знает о версиях; supersede_previous берёт advisory-блокировку ЮЛ и старые файлы FOR UPDATE; CounterpartyIndex.resolve возвращает только UUID | None; openpyxl уже в зависимостях (парсер ББ/ОФР). Фикстуры: ОСВ РБ 60/62 за три квартала 2024 и 01/58/66/67/76 только за Q3; Счастливочная 60/62/76 за три квартала, 01/02 за Q3.

Решения владельца 06.10.2026: объём — бэкенд + API + xlsx; OQ-03 — пропуск квартала даёт пробел по паре (ЮЛ, счёт), ключ склейки (счёт, контрагент); OQ-19 — блокировка закрывает все правки версии, перехват любому с CONSOLIDATION_EDIT по причине; статусы Среза 4 — автоматически по событиям документов + ручные CANCELLED и возврат в INITIATED; привязка документов к версии — явная, при подтверждении; экспорт — один лист как экран 30 плюс лист пробелов; признак автоматического сопоставления контрагента — в API (хранится).

Goals / Non-Goals

Goals: сводная ОСВ — чистая функция от версии (DECISION-5), одинаковая для API и экспорта; комплект версии объясним по consolidation_document; каждая правка версии защищена статусом и блокировкой; тест на фикстурах РБ из плана проходит. Non-Goals: см. proposal.

Decisions

D-1. Сводная ОСВ: одна выборка + чистый домен

repositories/consolidations.py:load_osv_rows(version_id) одним запросом отдаёт строки osv_entry привязанных ОСВ-документов версии (group_type = COUNTERPARTY, data_type IN (BU, SUM)) вместе с документом и ЮЛ. domain/consolidated_osv.py:build(version, members, documents, entries, index) без БД: выбор документов на пару (ЮЛ, счёт документа), приведение периодов, пробелы, признак, дорезолв, фильтры, порядок. Страница режется в памяти в сервисе; экспорт использует тот же результат. Альтернативы: агрегат в SQL (логика не тестируется, расходится с Python-резолвом) и таблица кэша (запрещена DECISION-5) — отвергнуты. Цена: весь свод версии в памяти на запрос; для групп до десятков тысяч строк приемлемо (OQ-121 — порог, после которого нужен кэш процесса).

D-2. Выбор документов на пару (ЮЛ, счёт) и приведение периодов (§2, OQ-03)

domain/consolidation_periods.py:select_sources(version_quarter, documents) -> Sources | Gap:

  • есть привязанный документ с is_cumulative = true и period_quarter = quarter версии → он один, как есть;
  • иначе нужны привязанные квартальные документы за каждый квартал Q1..Qn (n = квартал версии); для версии за Q1 квартальный документ Q1 равнозначен нарастающему; любого квартала нет → Gap(missing_quarters), строк нет;
  • нарастающий и квартальные одновременно → нарастающий (он полнее по определению). Склейка квартальных строк по ключу (account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn)): opening_* из строки Q1, turnover_* = Σ по кварталам, closing_* из строки Qn; отсутствующая в квартале строка = нули; две строки с одним ключом внутри квартала суммируются (решение без обсуждения #1). row_number строки свода = минимальный row_number среди источников последнего квартала (порядок как в файле Qn). Договоры (group_type = CONTRACT) в свод не входят, строка COUNTERPARTY уже содержит их сумму.

D-3. Признак контрагента и дорезолв

counterparty_id строки свода = первый непустой среди исходных строк (при расхождении между кварталами — из самого позднего квартала, строки с другим значением перечисляются в warnings строки). Пустой → CounterpartyIndex.resolve на лету, результат не пишется в БД: match_stored = false, match_kind = шаг резолва. Признак: GSK, если ЮЛ в consolidation_member версии; GK, если legal_entity.company_group_id = consolidation.company_group_id; иначе EXTERNAL (и при пустом контрагенте). Справочник читается целиком без policy (как в 2c).

D-4. counterparty_match_kind хранится (DBML v5.16)

Маппинг пишет INN | NAME | ALIAS по шагу, на котором нашёлся единственный кандидат (resolve возвращает Resolution(entity_id, kind)); ручное связывание — MANUAL, автоприменение — PROPAGATED; NULL при пустом counterparty_id. Миграция 0004 делает backfill SQL'ем: ИНН строки (без пробелов) = legal_entity.inn → INN; trim(name) = legal_entity.name → NAME; остальные с counterparty_id → ALIAS (приближение: ручных сопоставлений до Среза 4 не было). Альтернатива — вычислять на чтение: после ручного связывания появляется алиас, и ручное неотличимо от ALIAS.

D-5. Явная привязка документов (consolidation_document)

Документ подходит версии V, если legal_entity_id ∈ members(V), period_year = V.year, period_quarter не NULL и: is_cumulative (или NULL — ББ/ОФР всегда нарастающие) → period_quarter = V.quarter; квартальный → period_quarter ≤ V.quarter. Привязываются только CONFIRMED документы незаменённых файлов. Точки привязки: подтверждение документа (ко всем версиям «в работе»), создание версии и добавление ЮЛ в периметр (подхват: на одну тройку (тип, счёт, квартал) — документ с самым поздним confirmed_at). Точки отвязки: автозамена файла (документы заменённого файла — из всех версий «в работе»), удаление ЮЛ из периметра. Версии CANCELLED не трогаются. При привязке документ с той же тройкой у того же ЮЛ в той же версии заменяется (страховка от гонки автозамены). Удаление файла не меняется: привязываются только CONFIRMED, а их файл удалить уже нельзя (OQ-110 закрыт для Среза 4).

D-6. Автоматика статусов Среза 4 (§9.1)

Событие Переход changed_by
загрузка с consolidation_version_id, принят ≥ 1 файл INITIATED → DOCUMENTS_PROCESSING загрузивший
маппинг создал документ NEEDS_CHECK, подходящий версии DOCUMENTS_PROCESSING → DATA_CORRECTION stored_file.uploaded_by
подтверждение документа, привязанного к версии INITIATED/DOCUMENTS_PROCESSING → DATA_CORRECTION подтвердивший
POST …/status {CANCELLED, comment} любой «в работе» → CANCELLED пользователь
POST …/status {INITIATED} DOCUMENTS_PROCESSING/DATA_CORRECTION → INITIATED пользователь

Загрузка и подтверждение блокировкой не ограничиваются (решение владельца). Маппинг без uploaded_by переход пропускает с записью в лог (changed_by NOT NULL; решение без обсуждения #2). CANCELLED снимает активную блокировку (#3). Возврат в INITIATED привязки сохраняет (#4). Остальные переходы — 409 TRANSITION_NOT_ALLOWED. Каждый переход — consolidation_status_history + audit_event(CONSOLIDATION_STATUS_CHANGE).

D-7. Блокировка

consolidation_lock с частичным UNIQUE WHERE released_at IS NULL. POST …/lock без тела: активной нет → создать; своя → вернуть её (идемпотентно); чужая → 409 LOCKED_BY_OTHER (locked_by, locked_at в detail). С takeover_reason (непустой, ≤ 1000) при чужой: старая released_at = now, новая с taken_over_from, audit_event(CONSOLIDATION_LOCK_TAKEOVER). DELETE …/lock: своя → released_at = now, audit_event(CONSOLIDATION_LOCK, after.released = true); чужая → 409 NOT_LOCK_OWNER; нет активной → 204 (идемпотентно). Истечения по времени нет (OQ-119). Гонка двух POST: второй ловит IntegrityError уникального индекса → 409 LOCKED_BY_OTHER (как constraint_races в 2c).

D-8. Политика редактируемости

domain/policy.py: IN_WORK_STATUSES = {INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION}; ensure_version_editable(version, active_lock, user_id) → 409 VERSION_NOT_EDITABLE (статус не в работе), 409 LOCK_REQUIRED (нет активной), 409 LOCKED_BY_OTHER (чужая). Применяется к PUT …/members, POST …/status, POST …/osv/entries/{id}/counterparty. Видимость консолидации — company_groups_visible по её ГК; чужая — 404 CONSOLIDATION_NOT_FOUND/VERSION_NOT_FOUND. Порядок: право → видимость → статус/блокировка → операция.

D-9. Порядок блокировок строк

Правка версии: consolidation_version FOR UPDATE первой, затем остальные строки версии. Подтверждение документа: файл → документ → версии (по возрастанию id, FOR UPDATE). Автозамена: advisory ЮЛ → старые файлы → версии по id. Создание версии: consolidation по (company_group_id, year, quarter) защищена UNIQUE — гонка → 409 CONSOLIDATION_EXISTS. Единый порядок «файл → документ → версия» исключает встречные захваты.

D-10. Ручное сопоставление (§3, ТЗ DOC-221278 п. 7–13)

Одна транзакция: строка принадлежит документу, привязанному к версии (иначе 404 ENTRY_NOT_FOUND); выбранное ЮЛ существует (без policy); normalized = normalize_alias(counterparty_name); пустой → алиас не создаётся; владельцы normalized среди алиасов ≠ выбранному: без move_alias → 409 ALIAS_TAKEN (owners[]), с move_alias → их алиасы удаляются (audit LEGAL_ENTITY_CHANGE на каждый, как в Срезе 1); алиаса у выбранного нет → создаётся (audit LEGAL_ENTITY_CHANGE). Строке: counterparty_id, counterparty_match_kind = MANUAL; counterparty_name/inn не трогаются. Автоприменение: строки всех ОСВ-документов, привязанных к версии, с counterparty_name.strip() тем же, counterparty_id IS NULL, ИНН пустой или равный ИНН выбранного → counterparty_id, PROPAGATED. Один audit_event(OSV_COUNTERPARTY_MAP, object = osv_entry, consolidation_version_id) с before/after и propagated_entry_ids. Права: CONSOLIDATION_EDIT + своя блокировка; DIRECTORY_DELETE для переноса алиаса не требуется (отступление от aliases.remove, решение владельца 06.10.2026). Перевязка уже связанной строки разрешена (before хранит прежнее).

D-11. Кандидаты

GET …/counterparty-candidates?q=&scope=group|all&entry_id=: group (по умолчанию) — ЮЛ периметра и ЮЛ той же ГК, q необязателен; all — весь справочник без policy, q ≥ 3 символов (422 QUERY_TOO_SHORT, #5). Поиск: ИНН по префиксу, имя и алиасы — подстрока без регистра. Каждый кандидат: id, inn, name, company_group, gsk, gsz, counterparty_type относительно версии, recommended (нормализованное имя/алиас совпало с normalize_alias имени строки entry_id, если он передан). Рекомендуемые первыми, не больше 100 строк.

D-12. period_check не меняется

Подходящесть документа версии — функция (документ, версия) и считается в D-5; записывать её в document.period_check нельзя: один документ может входить в несколько версий (разные периметры и кварталы). period_check остаётся UNKNOWN до решения OQ-120 (показывать на карточке файла «подходит версиям: …»).

D-13. Чек-лист

GET …/checklist: строки — consolidation_member по имени ЮЛ; колонки — required_document_rule подхода версии (BASIC: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90), порядок: ББ, ОФР, счета по коду. Ячейка: CONFIRMED — пара полностью покрыта привязанными документами (D-2 без пробела; для ББ/ОФР — документ за квартал версии); иначе NEEDS_CHECK/RECOGNIZED — есть неподтверждённый подходящий документ (приоритет NEEDS_CHECK); иначе PROCESSING — у ЮЛ есть файл UPLOADED/PROCESSING; иначе ERROR — последний файл ЮЛ в ERROR; иначе MISSING. missing_quarters — пропущенные кварталы при частичном покрытии квартальными ОСВ. Для ОСВ правило закрывает только документ с osv_account_code = account правила (OQ-04). Ячейки PROCESSING по всем недостающим столбцам, пока файл не смаплен, — осознанная неточность (#6). Те же ячейки — в карточке версии у каждого ЮЛ.

D-14. Экспорт

GET …/osv/export → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, Content-Disposition: attachment; filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx. Лист «Сводная ОСВ»: Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт, Обороты Дт, Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы — числовые ячейки #,##0.00, рубли. Лист «Пробелы»: Компания ГК, Счёт, Нет ОСВ за кварталы; при пустом списке — одна ячейка «пробелов нет». Фильтры как у GET …/osv, без пагинации; audit_event(EXPORT, section EXPORT, object = consolidation_version, after = {filters, rows}). Строится в памяти (BytesIO), S3 не используется.

D-15. Создание консолидации

POST /consolidations {company_group_id, related_group_id, year, quarter}: ГК видима (404), ГСК принадлежит ГК и kind = gsk (422 RELATED_GROUP_MISMATCH), year 2000..2100; consolidation(reporting_date) по правилу банка (Q1 → 01.04.Y, Q2 → 01.07.Y, Q3 → 01.10.Y, Q4 → 01.01.Y+1; ASSUMPTION(OQ-40)); версия №1: INITIATED, CREATE, BASIC, goodwill_enabled = false (OQ-27), initiator_id = пользователь, initiator_office_id = его regional_office_id, is_in_work = true; члены — текущие члены ГСК (current_members), is_head_entity = false (гудвилл — Срез 6); меньше двух → 422 PERIMETER_TOO_SMALL, ничего не создаётся. ГСК не сохраняется (related_group_id NULL до верификации по DBML). Первая запись истории (from_status NULL), подхват документов (D-5), audit_event(CONSOLIDATION_CREATE). Блокировка на создателя не ставится (макет: «Взять в работу» — отдельное действие).

D-16. Схемы ответов

ConsolidationPeriodOut {consolidation_id, year, quarter, reporting_date, versions[]}; VersionBrief {id, version_no, status, scenario, approach, lock, created_at, members_count}; LockOut {locked_by, locked_by_name, locked_at, taken_over_from, takeover_reason}; VersionCard = VersionBrief + {consolidation, company_group {id, name}, initiator, initiator_office, goodwill_enabled, is_in_work, previous_version_id, members[] (ЮЛ + ячейки чек-листа), history[]}; ConsolidatedRow {entry_ids[], legal_entity_id, legal_entity_name, legal_entity_inn, account_code, account_root, period, is_cumulative, row_number, counterparty_name, counterparty_inn, counterparty_id, counterparty_entity_name, counterparty_type, match_kind, match_stored, opening_debit … closing_credit (Decimal → строка JSON), warnings[]}; OsvSummary {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[] {legal_entity_id, legal_entity_name, account_code, missing_quarters[]}}. Фильтры GET …/osv: legal_entity_id, account_root, counterparty_type, unmapped, match_kind (список), q; limit ≤ 2000 (по умолчанию 500), offset. Порядок: имя ЮЛ, account_code, row_number, entry_ids[0].

Risks / Trade-offs

  • Свод в памяти (D-1): большая группа × много счетов → секунды на запрос; порог — OQ-121.
  • PROCESSING в чек-листе по всем столбцам, пока файл не смаплен (D-13): шум, но честный.
  • Backfill match_kind (D-4) приближённый; на тестовых данных ручных сопоставлений до Среза 4 нет.
  • Общие строки osv_entry у нескольких версий: ручное сопоставление в одной версии меняет строку для всех — это и есть «хранимое ручное решение» DECISION-5, before_value аудита хранит прежнее.

Decisions made without discussion

# Дата Решение Обоснование Цена ошибки
1 2026-10-06 Две строки с одним ключом (счёт, имя, ИНН) внутри одного квартала суммируются при склейке иначе склейка неоднозначна; в нарастающих документах такие строки остаются раздельными сумма вместо двух строк в редком случае дубля имени; entry_ids сохраняет оба источника
2 2026-10-06 Маппинг без uploaded_by переход версии пропускает с логом changed_by NOT NULL; файлы без автора — только seed/тесты версия застревает в DOCUMENTS_PROCESSING; подтверждение документа всё равно переведёт в DATA_CORRECTION
3 2026-10-06 CANCELLED снимает активную блокировку аннулированную версию никто не редактирует нет
4 2026-10-06 Возврат в INITIATED сохраняет consolidation_document периметр правится отдельно и сам отвязывает документы удалённых ЮЛ при смене комплекта пользователь не видит «пустой» версии; привязки переподхватятся при повторном подтверждении
5 2026-10-06 scope=all требует q ≥ 3 символов весь справочник без policy не отдаём списком одна лишняя 422 для фронта
6 2026-10-06 Ячейка чек-листа PROCESSING для всех недостающих столбцов ЮЛ, пока файл в обработке до маппинга счёт файла неизвестен временный шум в чек-листе
7 2026-10-06 Алиасы при ручном сопоставлении пишут LEGAL_ENTITY_CHANGE в дополнение к OSV_COUNTERPARTY_MAP журнал справочника (Срез 1) остаётся полным два события на одно действие
8 2026-10-06 counterparty_id строки свода при расхождении между кварталами — из последнего квартала, расхождение в warnings нужно одно значение на строку; автоприменение выравнивает аналитик видит предупреждение, не ошибку
9 2026-10-06 GET …/osv и экспорт не требуют блокировки, только CONSOLIDATION_VIEW чтение нет
10 2026-10-06 ББ/ОФР (is_cumulative NULL) считаются нарастающими при привязке формы всегда за период с начала года нет
11 2026-10-06 R-1: ключ замены/выбора «самого позднего confirmed_at» при привязке = (document_type, osv_account_code, period_quarter, cumulative), cumulative = (is_cumulative is not False) тройка без признака нарастающего вытесняла бы нарастающий Q3 квартальным Q3 одной пары (ЮЛ, счёт), а сценарий «Нарастающий рядом с квартальными» требует обоих если владелец хотел «один документ на квартал» — нарастающий и квартальный вытесняли бы друг друга; меняется только ключ в bind_confirmed/on_document_confirmed
12 2026-10-06 R-2: lock_versions_for_entity блокирует FOR UPDATE ORDER BY id надмножество версий «в работе» (ЮЛ — член ИЛИ company_group_id совпадает), затем отдельным запросом возвращает только версии, где ЮЛ — член; создание версии берёт pg_advisory_xact_lock по ГК иначе confirm ∥ PUT …/members теряет привязку (не видит незакоммиченного члена), а автозамена ∥ подхват оставляет привязку заменённого документа тихо потерянная привязка документа (редко, чинится повторным добавлением ЮЛ в периметр)
13 2026-10-06 R-3: lock_document, lock_file_documents (и lock_file при участии в тех же циклах) берут FOR NO KEY UPDATE (with_for_update(key_share=True)) вставка consolidation_document берёт FOR KEY SHARE на документ, а FOR UPDATE подтверждения конфликтует с ней: confirm ∥ правка периметра/подхват взаимоблокируются нулевая: для остальных путей блокировка писателей не меняется, слабее только по FK-ссылкам
14 2026-10-06 R-4: Resolution(entity_id, kind) определяется в src/domain/osv_mapping.py; repositories/counterparties.py импортирует и реэкспортирует domain/consolidated_osv.py (mypy strict, без ORM/репозиториев) нуждается в нём в Resolver, домен не импортирует репозитории нулевая (перенос определения)
15 2026-10-06 R-5: Task 2 создаёт domain/checklist.py с EntityFilesState(has_in_progress, last_file_status) и entity_files_state(session, entity_ids); unconfirmed_documents(...) и build добавляет Task 8 план описывал entity_files_state и в Task 2, и в Task 8; build получает и files_state, и unbound нулевая (разнесение по задачам)
16 2026-10-06 osv_entry.counterparty_match_kind — последняя колонка таблицы (в ORM и DBML), а не сразу после counterparty_id ADD COLUMN в миграции 0004 добавляет колонку в конец, а test_migrated_schema_equals_orm_create_all сравнивает ordinal_position; пересоздание таблицы с 200 тыс. строк ради порядка колонок не оправдано косметика: порядок колонок в SELECT */диаграмме; на поведение не влияет
17 2026-10-06 R-8: коды 404 CONSOLIDATION_NOT_FOUND / VERSION_NOT_FOUND реализованы как текст сообщения при фиксированном коде NOT_FOUND конвенция Срезов 1–2c: NotFoundError с фиксированным кодом, как COMPANY_GROUP_NOT_FOUND если нужны отдельные коды — меняется конструктор ошибки и тесты
18 2026-10-06 R-9: in_work_only убирает период без оставшихся версий; невидимая/несуществующая ГК в списке → 404; неизвестная/невидимая ГСК → 404 (сообщение RELATED_GROUP_NOT_FOUND), видимая ГСК чужой ГК или kind = gsz → 422 RELATED_GROUP_MISMATCH естественное продолжение policy «невидимое = несуществующее» (404); несоответствие видимой ГСК — ошибка запроса расхождение с ожиданием фронта по пустому списку
19 2026-10-06 takeover_reason пустой или из одних пробелов считается отсутствующим: при чужой блокировке — 409 LOCKED_BY_OTHER, а не 422; при свободной версии причина не сохраняется (taken_over_from NULL, обычное взятие 201) спека требует «непустую» причину только для перехвата; при свободной версии перехватывать не у кого фронт не получит 422 на пустую причину; в БД нет «причины» у обычного взятия
20 2026-10-06 Комментарий POST …/status обрезается по краям; из одних пробелов — пустой (COMMENT_REQUIRED для CANCELLED); в cancelled_reason и историю пишется обрезанный текст «пустой комментарий» по спеке включает пробельный; обрезка не меняет смысла косметика: ведущие/хвостовые пробелы комментария теряются
21 2026-10-06 NOT_LOCK_OWNER отдаёт в detail locked_by и locked_at владельца (по аналогии с LOCKED_BY_OTHER) спека полей не перечисляет; фронту нужно показать, у кого блокировка нулевая (лишние поля ответа)
22 2026-10-06 Перехват пишет одно событие CONSOLIDATION_LOCK_TAKEOVER без отдельного CONSOLIDATION_LOCK на снятие прежней блокировки сценарий audit-trail «Перехват» — «одно событие» в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из before_value)
23 2026-10-06 LOCKED_BY_OTHER из ensure_version_editable (PUT …/members, POST …/status, POST …/osv/entries/{id}/counterparty) отдаёт locked_by, locked_at без locked_by_name; POST …/lock отдаёт и имя доменная функция без БД имени не знает; directory-policy требует только detail.locked_by, имя спека требует лишь у POST …/lock фронт для правок версии берёт имя владельца из карточки (lock.locked_by_name) или вызывающий сервис дополнит ответ
24 2026-10-06 CANCELLED снимает активную блокировку без отдельного события CONSOLIDATION_LOCK — снятие покрывает CONSOLIDATION_STATUS_CHANGE; uq_consolidation_lock_version_active зарегистрирован в UNIQUE_CONSTRAINT_CONFLICTS как LOCKED_BY_OTHER (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) таблица audit-trail относит CONSOLIDATION_LOCK к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая
25 2026-10-06 PUT …/members с составом, равным текущему, отвечает 200 с карточкой, ничего не пишет (ни членов, ни updated_at) и НЕ пишет MEMBERSHIP_CHANGE событие «периметр изменён» без изменения вводит в заблуждение и засоряет журнал; идемпотентный повтор запроса (двойной клик, ретрай) безопасен в журнале нет следа повторного сохранения того же состава (оно ничего не меняло)
26 2026-10-06 ENTITY_NOT_IN_GROUP покрывает три случая: ЮЛ другой ГК, ЮЛ без ГК и несуществующий id; legal_entity_ids — в порядке запроса. Проверка идёт ДО проверки размера (одно чужое ЮЛ → ENTITY_NOT_IN_GROUP, не PERIMETER_TOO_SMALL); дубли схлопываются ДО подсчёта размера ([a, a] → PERIMETER_TOO_SMALL) спека: «все ЮЛ SHALL принадлежать ГК» — несуществующее ЮЛ ей не принадлежит; существование отдельным кодом не различаем, чтобы не раскрывать чужие id (как 404 вместо 403) клиент не отличает «нет такого ЮЛ» от «ЮЛ чужой ГК»
27 2026-10-06 Члены, остающиеся в периметре, не пересоздаются (сохраняются id и is_head_entity); новые — is_head_entity = false. Непустая смена периметра выставляет consolidation_version.updated_by/at признак головной организации — решение аналитика, смена состава его не сбрасывает; updated_* показывают последнюю правку версии (как у transition) нулевая
28 2026-10-06 Общий пролог операций над версией вынесен в services/consolidation_guard.py: lock_visible_version (FOR UPDATE → видимость → 404) и lock_editable_version (+ активная блокировка и ensure_version_editable); VERSION_NOT_FOUND живёт там и реэкспортируется из services.consolidations. take/release берут только lock_visible_version (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде правка пролога меняет все операции сразу (задумано)
29 2026-10-06 До Task 6 отвязка ушедших ЮЛ — consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int: прямой DELETE строк consolidation_document документов этого ЮЛ в этой версии; bind_confirmed для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 до Task 6 добавленное ЮЛ не получает привязок документов (ячейка CONFIRMED появится в Task 6/8)
30 2026-10-06 R-2, гонка «создание версии ∥ подтверждение/автозамена» закрыта pg_advisory_xact_lock(hashtextextended('consolidation-group:<ГК>', 0)) (repositories.consolidations.lock_group_bindings): ConsolidationService.create берёт его сразу после проверки видимости ГК (до чтения ГСК, проверки дубля периода и подхвата), lock_versions_for_entity — первым шагом, если у ЮЛ есть ГК (ЮЛ без ГК не может быть членом версии). Полный порядок: файл → документ → advisory ГК → версии; автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; PUT …/members advisory не берёт. Тесты на двух сессиях в обе стороны (test_create_waits_for_confirm_…, test_confirm_waits_for_create_…), мутанты без advisory в любой из сторон их роняют новая версия невидима до commit, FOR UPDATE версий её не ловит; advisory по ГК — минимальная сериализация, не трогающая правку периметра создание консолидаций одной ГК (любых периодов) сериализуется с подтверждениями и автозаменами документов ЮЛ этой ГК — ожидание на время короткой транзакции; параллельные создания одного периода теперь получают 409 CONSOLIDATION_EXISTS проверкой, а не UNIQUE. Все подтверждения (и автозамены) документов ЮЛ одной ГК теперь сериализуются на 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) — пока теоретически Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в DOCUMENTS_PROCESSING без файлов (исправимо: вручную вернуть в INITIATED); возможное последующее исправление — переход после коммита запроса.
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 после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в DOCUMENTS_PROCESSING без файлов (исправимо: вручную вернуть в 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: строка сопоставлена с , в своде — ») попадают только строки с ДРУГИМ непустым 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)