Proposal, design (D-1…D-16, решения без обсуждения), tasks и дельты спек: новые capability consolidations-api и consolidated-osv; изменения data-model (DBML v5.16, counterparty_match_kind), documents-api (привязка при подтверждении), primary-files (consolidation_version_id, отвязка при автозамене), osv-result-mapping (match_kind, NEEDS_CHECK → DATA_CORRECTION), directory-policy (редактируемость версии), audit-trail (события консолидации). Решения владельца 06.10.2026: OQ-03 пробел, OQ-19 блокировка, статусы по событиям, явная привязка, экспорт одним листом. Refs RSHB/arm-core#18 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
24 KiB
Design: Срез 4 — консолидация и сводная ОСВ
Context
Обследование 06.10.2026 (.wt/slice-4): модели шести таблиц консолидации и required_document_rule есть (1:1 с DBML
v5.15), сервисов/репозиториев/эндпоинтов/seed консолидаций нет; CONSOLIDATION_VIEW/EDIT выданы ролям, но не
используются; domain/policy.py знает только видимость справочников; DocumentService.confirm не знает о версиях;
supersede_previous берёт advisory-блокировку ЮЛ и старые файлы FOR UPDATE; CounterpartyIndex.resolve возвращает
только UUID | None; openpyxl уже в зависимостях (парсер ББ/ОФР). Фикстуры: ОСВ РБ 60/62 за три квартала 2024 и
01/58/66/67/76 только за Q3; Счастливочная 60/62/76 за три квартала, 01/02 за Q3.
Решения владельца 06.10.2026: объём — бэкенд + API + xlsx; OQ-03 — пропуск квартала даёт пробел по паре
(ЮЛ, счёт), ключ склейки (счёт, контрагент); OQ-19 — блокировка закрывает все правки версии, перехват любому с
CONSOLIDATION_EDIT по причине; статусы Среза 4 — автоматически по событиям документов + ручные CANCELLED и возврат
в INITIATED; привязка документов к версии — явная, при подтверждении; экспорт — один лист как экран 30 плюс лист
пробелов; признак автоматического сопоставления контрагента — в API (хранится).
Goals / Non-Goals
Goals: сводная ОСВ — чистая функция от версии (DECISION-5), одинаковая для API и экспорта; комплект версии
объясним по consolidation_document; каждая правка версии защищена статусом и блокировкой; тест на фикстурах РБ из
плана проходит. Non-Goals: см. proposal.
Decisions
D-1. Сводная ОСВ: одна выборка + чистый домен
repositories/consolidations.py:load_osv_rows(version_id) одним запросом отдаёт строки osv_entry привязанных
ОСВ-документов версии (group_type = COUNTERPARTY, data_type IN (BU, SUM)) вместе с документом и ЮЛ.
domain/consolidated_osv.py:build(version, members, documents, entries, index) без БД: выбор документов на пару
(ЮЛ, счёт документа), приведение периодов, пробелы, признак, дорезолв, фильтры, порядок. Страница режется в памяти в
сервисе; экспорт использует тот же результат. Альтернативы: агрегат в SQL (логика не тестируется, расходится с
Python-резолвом) и таблица кэша (запрещена DECISION-5) — отвергнуты. Цена: весь свод версии в памяти на запрос; для
групп до десятков тысяч строк приемлемо (OQ-121 — порог, после которого нужен кэш процесса).
D-2. Выбор документов на пару (ЮЛ, счёт) и приведение периодов (§2, OQ-03)
domain/consolidation_periods.py:select_sources(version_quarter, documents) -> Sources | Gap:
- есть привязанный документ с
is_cumulative = trueиperiod_quarter = quarter версии→ он один, как есть; - иначе нужны привязанные квартальные документы за каждый квартал
Q1..Qn(n= квартал версии); для версии за Q1 квартальный документ Q1 равнозначен нарастающему; любого квартала нет →Gap(missing_quarters), строк нет; - нарастающий и квартальные одновременно → нарастающий (он полнее по определению).
Склейка квартальных строк по ключу
(account_code строки, counterparty_name.strip(), normalize_inn(counterparty_inn)):opening_*из строки Q1,turnover_*= Σ по кварталам,closing_*из строки Qn; отсутствующая в квартале строка = нули; две строки с одним ключом внутри квартала суммируются (решение без обсуждения #1).row_numberстроки свода = минимальныйrow_numberсреди источников последнего квартала (порядок как в файле Qn). Договоры (group_type = CONTRACT) в свод не входят, строка COUNTERPARTY уже содержит их сумму.
D-3. Признак контрагента и дорезолв
counterparty_id строки свода = первый непустой среди исходных строк (при расхождении между кварталами — из самого
позднего квартала, строки с другим значением перечисляются в warnings строки). Пустой → CounterpartyIndex.resolve
на лету, результат не пишется в БД: match_stored = false, match_kind = шаг резолва. Признак: GSK, если ЮЛ в
consolidation_member версии; GK, если legal_entity.company_group_id = consolidation.company_group_id; иначе
EXTERNAL (и при пустом контрагенте). Справочник читается целиком без policy (как в 2c).
D-4. counterparty_match_kind хранится (DBML v5.16)
Маппинг пишет INN | NAME | ALIAS по шагу, на котором нашёлся единственный кандидат (resolve возвращает
Resolution(entity_id, kind)); ручное связывание — MANUAL, автоприменение — PROPAGATED; NULL при пустом
counterparty_id. Миграция 0004 делает backfill SQL'ем: ИНН строки (без пробелов) = legal_entity.inn → INN;
trim(name) = legal_entity.name → NAME; остальные с counterparty_id → ALIAS (приближение: ручных сопоставлений
до Среза 4 не было). Альтернатива — вычислять на чтение: после ручного связывания появляется алиас, и ручное
неотличимо от ALIAS.
D-5. Явная привязка документов (consolidation_document)
Документ подходит версии V, если legal_entity_id ∈ members(V), period_year = V.year, period_quarter не
NULL и: is_cumulative (или NULL — ББ/ОФР всегда нарастающие) → period_quarter = V.quarter; квартальный →
period_quarter ≤ V.quarter. Привязываются только CONFIRMED документы незаменённых файлов. Точки привязки:
подтверждение документа (ко всем версиям «в работе»), создание версии и добавление ЮЛ в периметр (подхват: на одну
тройку (тип, счёт, квартал) — документ с самым поздним confirmed_at). Точки отвязки: автозамена файла (документы
заменённого файла — из всех версий «в работе»), удаление ЮЛ из периметра. Версии CANCELLED не трогаются.
При привязке документ с той же тройкой у того же ЮЛ в той же версии заменяется (страховка от гонки автозамены).
Удаление файла не меняется: привязываются только CONFIRMED, а их файл удалить уже нельзя (OQ-110 закрыт для
Среза 4).
D-6. Автоматика статусов Среза 4 (§9.1)
| Событие | Переход | changed_by |
|---|---|---|
загрузка с consolidation_version_id, принят ≥ 1 файл |
INITIATED → DOCUMENTS_PROCESSING |
загрузивший |
маппинг создал документ NEEDS_CHECK, подходящий версии |
DOCUMENTS_PROCESSING → DATA_CORRECTION |
stored_file.uploaded_by |
| подтверждение документа, привязанного к версии | INITIATED/DOCUMENTS_PROCESSING → DATA_CORRECTION |
подтвердивший |
POST …/status {CANCELLED, comment} |
любой «в работе» → CANCELLED |
пользователь |
POST …/status {INITIATED} |
DOCUMENTS_PROCESSING/DATA_CORRECTION → INITIATED |
пользователь |
Загрузка и подтверждение блокировкой не ограничиваются (решение владельца). Маппинг без uploaded_by переход
пропускает с записью в лог (changed_by NOT NULL; решение без обсуждения #2). CANCELLED снимает активную
блокировку (#3). Возврат в INITIATED привязки сохраняет (#4). Остальные переходы — 409 TRANSITION_NOT_ALLOWED.
Каждый переход — consolidation_status_history + audit_event(CONSOLIDATION_STATUS_CHANGE).
D-7. Блокировка
consolidation_lock с частичным UNIQUE WHERE released_at IS NULL. POST …/lock без тела: активной нет → создать;
своя → вернуть её (идемпотентно); чужая → 409 LOCKED_BY_OTHER (locked_by, locked_at в detail). С
takeover_reason (непустой, ≤ 1000) при чужой: старая released_at = now, новая с taken_over_from,
audit_event(CONSOLIDATION_LOCK_TAKEOVER). DELETE …/lock: своя → released_at = now,
audit_event(CONSOLIDATION_LOCK, after.released = true); чужая → 409 NOT_LOCK_OWNER; нет активной → 204
(идемпотентно). Истечения по времени нет (OQ-119). Гонка двух POST: второй ловит IntegrityError уникального индекса
→ 409 LOCKED_BY_OTHER (как constraint_races в 2c).
D-8. Политика редактируемости
domain/policy.py: IN_WORK_STATUSES = {INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION};
ensure_version_editable(version, active_lock, user_id) → 409 VERSION_NOT_EDITABLE (статус не в работе), 409
LOCK_REQUIRED (нет активной), 409 LOCKED_BY_OTHER (чужая). Применяется к PUT …/members, POST …/status,
POST …/osv/entries/{id}/counterparty. Видимость консолидации — company_groups_visible по её ГК; чужая — 404
CONSOLIDATION_NOT_FOUND/VERSION_NOT_FOUND. Порядок: право → видимость → статус/блокировка → операция.
D-9. Порядок блокировок строк
Правка версии: consolidation_version FOR UPDATE первой, затем остальные строки версии. Подтверждение документа:
файл → документ → версии (по возрастанию id, FOR UPDATE). Автозамена: advisory ЮЛ → старые файлы → версии по
id. Создание версии: consolidation по (company_group_id, year, quarter) защищена UNIQUE — гонка → 409
CONSOLIDATION_EXISTS. Единый порядок «файл → документ → версия» исключает встречные захваты.
D-10. Ручное сопоставление (§3, ТЗ DOC-221278 п. 7–13)
Одна транзакция: строка принадлежит документу, привязанному к версии (иначе 404 ENTRY_NOT_FOUND); выбранное ЮЛ
существует (без policy); normalized = normalize_alias(counterparty_name); пустой → алиас не создаётся; владельцы
normalized среди алиасов ≠ выбранному: без move_alias → 409 ALIAS_TAKEN (owners[]), с move_alias → их
алиасы удаляются (audit LEGAL_ENTITY_CHANGE на каждый, как в Срезе 1); алиаса у выбранного нет → создаётся
(audit LEGAL_ENTITY_CHANGE). Строке: counterparty_id, counterparty_match_kind = MANUAL; counterparty_name/inn
не трогаются. Автоприменение: строки всех ОСВ-документов, привязанных к версии, с counterparty_name.strip() тем же,
counterparty_id IS NULL, ИНН пустой или равный ИНН выбранного → counterparty_id, PROPAGATED. Один
audit_event(OSV_COUNTERPARTY_MAP, object = osv_entry, consolidation_version_id) с before/after и propagated_entry_ids.
Права: CONSOLIDATION_EDIT + своя блокировка; DIRECTORY_DELETE для переноса алиаса не требуется (отступление от
aliases.remove, решение владельца 06.10.2026). Перевязка уже связанной строки разрешена (before хранит прежнее).
D-11. Кандидаты
GET …/counterparty-candidates?q=&scope=group|all&entry_id=: group (по умолчанию) — ЮЛ периметра и ЮЛ той же ГК,
q необязателен; all — весь справочник без policy, q ≥ 3 символов (422 QUERY_TOO_SHORT, #5). Поиск: ИНН по
префиксу, имя и алиасы — подстрока без регистра. Каждый кандидат: id, inn, name, company_group, gsk, gsz,
counterparty_type относительно версии, recommended (нормализованное имя/алиас совпало с normalize_alias
имени строки entry_id, если он передан). Рекомендуемые первыми, не больше 100 строк.
D-12. period_check не меняется
Подходящесть документа версии — функция (документ, версия) и считается в D-5; записывать её в document.period_check
нельзя: один документ может входить в несколько версий (разные периметры и кварталы). period_check остаётся
UNKNOWN до решения OQ-120 (показывать на карточке файла «подходит версиям: …»).
D-13. Чек-лист
GET …/checklist: строки — consolidation_member по имени ЮЛ; колонки — required_document_rule подхода версии
(BASIC: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90), порядок: ББ, ОФР, счета по коду. Ячейка:
CONFIRMED — пара полностью покрыта привязанными документами (D-2 без пробела; для ББ/ОФР — документ за квартал
версии); иначе NEEDS_CHECK/RECOGNIZED — есть неподтверждённый подходящий документ (приоритет NEEDS_CHECK);
иначе PROCESSING — у ЮЛ есть файл UPLOADED/PROCESSING; иначе ERROR — последний файл ЮЛ в ERROR; иначе
MISSING. missing_quarters — пропущенные кварталы при частичном покрытии квартальными ОСВ. Для ОСВ правило закрывает
только документ с osv_account_code = account правила (OQ-04). Ячейки PROCESSING по всем недостающим столбцам,
пока файл не смаплен, — осознанная неточность (#6). Те же ячейки — в карточке версии у каждого ЮЛ.
D-14. Экспорт
GET …/osv/export → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, Content-Disposition: attachment; filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx. Лист «Сводная ОСВ»:
Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт, Обороты Дт,
Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы — числовые
ячейки #,##0.00, рубли. Лист «Пробелы»: Компания ГК, Счёт, Нет ОСВ за кварталы; при пустом списке — одна ячейка
«пробелов нет». Фильтры как у GET …/osv, без пагинации; audit_event(EXPORT, section EXPORT, object = consolidation_version, after = {filters, rows}). Строится в памяти (BytesIO), S3 не используется.
D-15. Создание консолидации
POST /consolidations {company_group_id, related_group_id, year, quarter}: ГК видима (404), ГСК принадлежит ГК и
kind = gsk (422 RELATED_GROUP_MISMATCH), year 2000..2100; consolidation(reporting_date) по правилу банка
(Q1 → 01.04.Y, Q2 → 01.07.Y, Q3 → 01.10.Y, Q4 → 01.01.Y+1; ASSUMPTION(OQ-40)); версия №1: INITIATED, CREATE,
BASIC, goodwill_enabled = false (OQ-27), initiator_id = пользователь, initiator_office_id = его
regional_office_id, is_in_work = true; члены — текущие члены ГСК (current_members), is_head_entity = false
(гудвилл — Срез 6); меньше двух → 422 PERIMETER_TOO_SMALL, ничего не создаётся. ГСК не сохраняется
(related_group_id NULL до верификации по DBML). Первая запись истории (from_status NULL), подхват документов (D-5),
audit_event(CONSOLIDATION_CREATE). Блокировка на создателя не ставится (макет: «Взять в работу» — отдельное действие).
D-16. Схемы ответов
ConsolidationPeriodOut {consolidation_id, year, quarter, reporting_date, versions[]};
VersionBrief {id, version_no, status, scenario, approach, lock, created_at, members_count};
LockOut {locked_by, locked_by_name, locked_at, taken_over_from, takeover_reason};
VersionCard = VersionBrief + {consolidation, company_group {id, name}, initiator, initiator_office, goodwill_enabled, is_in_work, previous_version_id, members[] (ЮЛ + ячейки чек-листа), history[]};
ConsolidatedRow {entry_ids[], legal_entity_id, legal_entity_name, legal_entity_inn, account_code, account_root, period, is_cumulative, row_number, counterparty_name, counterparty_inn, counterparty_id, counterparty_entity_name, counterparty_type, match_kind, match_stored, opening_debit … closing_credit (Decimal → строка JSON), warnings[]};
OsvSummary {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[] {legal_entity_id, legal_entity_name, account_code, missing_quarters[]}}. Фильтры GET …/osv: legal_entity_id, account_root,
counterparty_type, unmapped, match_kind (список), q; limit ≤ 2000 (по умолчанию 500), offset. Порядок:
имя ЮЛ, account_code, row_number, entry_ids[0].
Risks / Trade-offs
- Свод в памяти (D-1): большая группа × много счетов → секунды на запрос; порог — OQ-121.
PROCESSINGв чек-листе по всем столбцам, пока файл не смаплен (D-13): шум, но честный.- Backfill
match_kind(D-4) приближённый; на тестовых данных ручных сопоставлений до Среза 4 нет. - Общие строки
osv_entryу нескольких версий: ручное сопоставление в одной версии меняет строку для всех — это и есть «хранимое ручное решение» DECISION-5,before_valueаудита хранит прежнее.
Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-06 | Две строки с одним ключом (счёт, имя, ИНН) внутри одного квартала суммируются при склейке | иначе склейка неоднозначна; в нарастающих документах такие строки остаются раздельными | сумма вместо двух строк в редком случае дубля имени; entry_ids сохраняет оба источника |
| 2 | 2026-10-06 | Маппинг без uploaded_by переход версии пропускает с логом |
changed_by NOT NULL; файлы без автора — только seed/тесты |
версия застревает в DOCUMENTS_PROCESSING; подтверждение документа всё равно переведёт в DATA_CORRECTION |
| 3 | 2026-10-06 | CANCELLED снимает активную блокировку |
аннулированную версию никто не редактирует | нет |
| 4 | 2026-10-06 | Возврат в INITIATED сохраняет consolidation_document |
периметр правится отдельно и сам отвязывает документы удалённых ЮЛ | при смене комплекта пользователь не видит «пустой» версии; привязки переподхватятся при повторном подтверждении |
| 5 | 2026-10-06 | scope=all требует q ≥ 3 символов |
весь справочник без policy не отдаём списком | одна лишняя 422 для фронта |
| 6 | 2026-10-06 | Ячейка чек-листа PROCESSING для всех недостающих столбцов ЮЛ, пока файл в обработке |
до маппинга счёт файла неизвестен | временный шум в чек-листе |
| 7 | 2026-10-06 | Алиасы при ручном сопоставлении пишут LEGAL_ENTITY_CHANGE в дополнение к OSV_COUNTERPARTY_MAP |
журнал справочника (Срез 1) остаётся полным | два события на одно действие |
| 8 | 2026-10-06 | counterparty_id строки свода при расхождении между кварталами — из последнего квартала, расхождение в warnings |
нужно одно значение на строку; автоприменение выравнивает | аналитик видит предупреждение, не ошибку |
| 9 | 2026-10-06 | GET …/osv и экспорт не требуют блокировки, только CONSOLIDATION_VIEW |
чтение | нет |
| 10 | 2026-10-06 | ББ/ОФР (is_cumulative NULL) считаются нарастающими при привязке |
формы всегда за период с начала года | нет |