Andrey Epifancev 6e5ab834ec spec(slice-4): OpenSpec change консолидации — версии, периметр, блокировка, привязка документов, сводная ОСВ, экспорт
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
2026-10-06 02:52:23 +04:00

24 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) считаются нарастающими при привязке формы всегда за период с начала года нет