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
This commit is contained in:
Andrey Epifancev 2026-10-06 02:52:23 +04:00
parent 714832efd9
commit 6e5ab834ec
11 changed files with 907 additions and 0 deletions

View File

@ -0,0 +1,197 @@
# 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) считаются нарастающими при привязке | формы всегда за период с начала года | нет |

View File

@ -0,0 +1,87 @@
# Proposal: Срез 4 — консолидация: версии, периметр, блокировка, привязка документов, сводная ОСВ, экспорт xlsx
**Срез:** 4 (`../docs/context/09-delivery-plan.md`). **Репозиторий:** arm-core. **Issue:** RSHB/arm-core#18.
**Родительский change:** `../docs/openspec/changes/slice-4-consolidation` (DBML v5.16, правила §2/§3/§9 по факту, OQ;
RSHB/arm-ka-docs#19). Объём по решению владельца 06.10.2026: **бэкенд, API, экспорт xlsx**; UI (список, карточка,
состав, вкладка ОСВ) делает другой разработчик по этим спекам.
## Why
После Среза 2c строки ОСВ лежат в `osv_entry` по документам, но объекта «консолидация» в ядре нет: модели шести таблиц
есть, сервисов, репозиториев и эндпоинтов нет; права `CONSOLIDATION_*` ни на одном маршруте не используются;
`period_check = UNKNOWN`. Без Среза 4 нет периметра версии, нет сводной ОСВ, на которой стоят шахматка и ВГО (Срез 5),
нет чек-листа комплекта и некуда показывать результат ручного сопоставления контрагентов.
## What Changes
- **DBML v5.16**: enum `counterparty_match_kind` (`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`) и колонка
`osv_entry.counterparty_match_kind` — как появился `counterparty_id` (решение владельца 06.10.2026: строки,
сопоставленные автоматически по имени или алиасу, должны выделяться в API). Миграция `0004_dbml_v5_16` с backfill
по уже смапленным строкам. Таблиц 43, enum'ов 28.
- **Маппинг (2c)** пишет `counterparty_match_kind` по шагу резолва (`CounterpartyIndex.resolve` возвращает шаг).
- **Консолидации** (`consolidations-api`): `POST /consolidations` (ГК + ГСК + год + квартал → `consolidation`,
`consolidation_version` №1 `INITIATED`, `consolidation_member` из текущего состава ГСК, минимум 2 ЮЛ);
список по ГК с деревом версий; карточка версии с периметром, ответственным и ячейками чек-листа; `PUT …/members`;
блокировка `POST/DELETE …/lock` с перехватом по причине; `POST …/status` (в Срезе 4 — только `CANCELLED` и возврат
в `INITIATED`); чек-лист `GET …/checklist` (перенесён из Среза 3: без него негде показать пробелы кварталов).
- **Привязка документов** (`documents-api`, `primary-files`): подтверждение документа привязывает его
(`consolidation_document`) ко всем версиям в работе, которым он подходит по периметру и периоду, и двигает статус
`INITIATED`/`DOCUMENTS_PROCESSING → DATA_CORRECTION`; создание версии и добавление ЮЛ подхватывают уже подтверждённые
документы; автозамена файла и удаление ЮЛ из периметра снимают привязку. Загрузка файла принимает необязательный
`consolidation_version_id` (`INITIATED → DOCUMENTS_PROCESSING`); маппинг документа `NEEDS_CHECK` для ЮЛ периметра —
`DOCUMENTS_PROCESSING → DATA_CORRECTION`.
- **Сводная ОСВ** (`consolidated-osv`): чистая функция `domain/consolidated_osv.py` от версии, периметра и привязанных
ОСВ: приведение квартальных ОСВ к нарастающим (§2: ключ (счёт, контрагент), пропуск квартала → пробел, строки не
строятся), признак контрагента ГСК/ГК/Внешний, дорезолв незаполненного `counterparty_id` на чтение (не сохраняется),
`match_kind`/`match_stored`. `GET …/osv` (страница, фильтры), `GET …/osv/summary` (счётчики, пробелы),
`GET …/counterparty-candidates`, `POST …/osv/entries/{id}/counterparty` (алиас, перенос алиаса, автоприменение,
`OSV_COUNTERPARTY_MAP`), `GET …/osv/export` (xlsx, openpyxl, два листа, аудит `EXPORT`).
- **Политика** (`directory-policy`): видимость консолидации = видимость её ГК; правки версии — только в статусе «в
работе» и при своей активной блокировке.
- **Аудит** (`audit-trail`): `CONSOLIDATION_CREATE`, `MEMBERSHIP_CHANGE`, `CONSOLIDATION_LOCK`,
`CONSOLIDATION_LOCK_TAKEOVER`, `CONSOLIDATION_STATUS_CHANGE`, `OSV_COUNTERPARTY_MAP`, `EXPORT` — все с
`consolidation_version_id`.
- **Тесты**: домен без БД (приведение периодов, пробелы, признак), API на PostgreSQL, гонки блокировок, сквозной
`fixtures`-тест: консолидация группы «Русский бисквит» за Q3 2024, одно ручное сопоставление → ГСК у всех строк
«Пекарня Счастливочная».
- **Документы**: README среза, CLAUDE.md, спеки.
## Capabilities
### New Capabilities
- `consolidations-api`: создание, список, карточка, периметр, блокировка, статусы, чек-лист.
- `consolidated-osv`: расчёт сводной ОСВ, кандидаты и ручное сопоставление контрагента, экспорт xlsx.
### Modified Capabilities
- `data-model`: схема равна DBML **v5.16**, миграции 0001 → 0004.
- `documents-api`: подтверждение документа привязывает его к версиям и меняет их статус.
- `primary-files`: загрузка с `consolidation_version_id`; автозамена снимает привязку заменённых документов.
- `osv-result-mapping`: `counterparty_match_kind` при резолве; переход версии в `DATA_CORRECTION` по `NEEDS_CHECK`.
- `directory-policy`: видимость консолидаций и правило редактируемости версии.
- `audit-trail`: события консолидации.
## Impact
- **Таблицы DBML v5.16:** `consolidation`, `consolidation_version`, `consolidation_status_history`, `consolidation_lock`,
`consolidation_member`, `consolidation_document`, `osv_entry` (новая колонка), `legal_entity_alias`,
`required_document_rule`, `audit_event`.
- **Правила:** §2 (приведение периодов), §3 (сводная ОСВ, ручное сопоставление), §9.1 (статусы Среза 4), §9.3
(`period_check` остаётся `UNKNOWN`, см. design D-12), §9.5 (отвязка при автозамене), §10 (права), `04-data-model-guide`
(«что не хранится»).
- **Код:** новые `api/endpoints/consolidations/`, `services/consolidations.py`, `services/consolidation_locks.py`,
`services/consolidation_documents.py`, `services/consolidated_osv.py`, `services/osv_export.py`,
`repositories/consolidations.py`, `domain/consolidated_osv.py`, `domain/consolidation_periods.py`,
`schemas/consolidations.py`; правки `services/documents.py`, `services/result_mapping.py`, `services/primary_files.py`,
`repositories/counterparties.py`, `domain/policy.py`, `api/endpoints/files/upload.py`, миграция `0004`.
- **Потребители:** фронт (2d/4-UI) — по этим спекам; tabular-parser и контракт v1.1 не меняются.
## Non-goals
- Маршрут согласования после `DATA_CORRECTION` (`CONSOLIDATION_RF` и далее), УОБ, `ex_snapshot`, «Добавить версию»,
сценарий `CONFIRM`, подход `EXTENDED` — Срезы 6–7. Переходы в эти статусы отвечают 409 `TRANSITION_NOT_ALLOWED`.
- Запрет загрузки/подтверждения документов в статусах после `DATA_CORRECTION` (ТЗ 03_9 A1) — Срез 7, OQ-118.
- `period_check` документа по версии (OQ-120): остаётся `UNKNOWN`; подходящесть документа версии считается на лету.
- Шахматка, пары ВГО, корректировки (Срез 5), ББ/ОФР из Excel (2b), PDF (Срез 3), UI (другой разработчик).
- CSV чек-листа (макет `37`) — фронт строит из JSON.
- Отвязка контрагента от строки (обратное действие): перевязка покрывает исправление ошибки.
- Зоны ответственности в политике (OQ-86), истечение блокировки по времени (OQ-119).

View File

@ -0,0 +1,30 @@
# Spec Delta: audit-trail
## ADDED Requirements
### Requirement: События консолидации
Действия с консолидацией SHALL писать `audit_event` через сервис аудита с заполненным `consolidation_version_id`:
| Действие | `action` | `section` | `object_type` | `before_value` / `after_value` |
|---|---|---|---|---|
| консолидация создана | `CONSOLIDATION_CREATE` | `CONSOLIDATION` | `consolidation_version` | — / `{consolidation_id, year, quarter, members}` |
| периметр изменён | `MEMBERSHIP_CHANGE` | `CONSOLIDATION` | `consolidation_version` | списки ЮЛ до / после |
| взята в работу, снята | `CONSOLIDATION_LOCK` | `CONSOLIDATION` | `consolidation_version` | — / `{locked_by, released}` |
| перехват | `CONSOLIDATION_LOCK_TAKEOVER` | `CONSOLIDATION` | `consolidation_version` | `{locked_by: прежний}` / `{locked_by: новый}`, `comment` = причина |
| смена статуса (ручная и автоматическая) | `CONSOLIDATION_STATUS_CHANGE` | `CONSOLIDATION` | `consolidation_version` | `{status}` / `{status}`, `comment` |
| контрагент сопоставлен вручную | `OSV_COUNTERPARTY_MAP` | `CONSOLIDATION` | `osv_entry` | `{counterparty_id, match_kind}` / `{counterparty_id, match_kind, propagated_entry_ids, alias_created, alias_moved_from}` |
| экспорт свода | `EXPORT` | `EXPORT` | `consolidation_version` | — / `{report: "consolidated_osv", filters, rows}` |
Автоматические переходы статуса SHALL писаться с `user_id` инициатора события (загрузивший, подтвердивший,
`stored_file.uploaded_by`). Создание и удаление алиасов при ручном сопоставлении SHALL дополнительно писать
`LEGAL_ENTITY_CHANGE` (раздел `DIRECTORIES`, как в Срезе 1). `FILE_REPLACE` SHALL добавлять в `after_value`
`unbound_version_ids`.
#### Scenario: Перехват
- **WHEN** Б перехватывает блокировку у А с причиной «Отпуск»
- **THEN** одно событие `CONSOLIDATION_LOCK_TAKEOVER` с `before_value.locked_by = A`, `after_value.locked_by = B`,
`comment = "Отпуск"`, `consolidation_version_id` версии
#### Scenario: Автоматический переход
- **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION`
- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED`

View File

@ -0,0 +1,145 @@
# Spec Delta: consolidated-osv
## Purpose
Сводная ОСВ версии как чистая функция (DECISION-5; §2, §3 `05-domain-rules.md`; ТЗ DOC-221278; макет `30`,
`32-osv-no-inn`, `32-osv-map-counterparty`): приведение квартальных ОСВ к нарастающим, признак контрагента, кандидаты и
ручное сопоставление, экспорт xlsx. Маршруты — под `/api/v1/consolidations/versions/{id}`.
## ADDED Requirements
### Requirement: Выбор источников и приведение периодов
Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов,
привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии
→ единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала
версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; при наличии и
нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с
`missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки
SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из
строки `Q1`, `turnover_*` = сумма по кварталам, `closing_*` из строки последнего квартала; строка, отсутствующая в
квартале, считается нулями; две строки с одним ключом внутри квартала суммируются. `entry_ids` строки свода SHALL
перечислять все исходные `osv_entry`. Расчёт — функция в `domain/` без ORM; результат MUST NOT сохраняться.
#### Scenario: Три квартала
- **WHEN** версия 2024 Q3; у ЮЛ привязаны квартальные ОСВ 60 за Q1, Q2, Q3; контрагент «А» есть во всех трёх со
значениями `opening_debit` 100/300/450 и `turnover_debit` 200/150/50, `closing_debit` 300/450/500
- **THEN** одна строка «А»: `opening_debit = 100`, `turnover_debit = 400`, `closing_debit = 500`, `period = "2024 Q3"`,
`is_cumulative = false`, `entry_ids` — три идентификатора
#### Scenario: Контрагент только во втором квартале
- **WHEN** контрагент «Б» есть только в ОСВ Q2 (`opening 0`, `turnover_debit 70`, `closing_debit 70`)
- **THEN** строка «Б»: `opening_* = 0`, `turnover_debit = 70`, `closing_* = 0`
#### Scenario: Пробел
- **WHEN** у ЮЛ привязаны ОСВ 60 за Q1 и Q3, за Q2 нет
- **THEN** строк по счёту 60 этого ЮЛ нет; `summary.gaps` содержит `{legal_entity_id, account_code: "60",
missing_quarters: ["Q2"]}`
#### Scenario: Нарастающий рядом с квартальными
- **WHEN** у ЮЛ привязаны квартальные ОСВ 62 за Q1–Q3 и нарастающая ОСВ 62 за Q3
- **THEN** строки берутся из нарастающей один в один, `is_cumulative = true`
### Requirement: Строки свода, признак и дорезолв
Строка свода SHALL содержать: `entry_ids`, ЮЛ (`id`, `name`, `inn`), `account_code`, `account_root`, `period`
(`"<год> Q<n>"`), `is_cumulative`, `row_number`, `counterparty_name` и `counterparty_inn` как в файле,
`counterparty_id`, `counterparty_entity_name`, `counterparty_type` ∈ {`GSK`, `GK`, `EXTERNAL`}, `match_kind` ∈
{`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`, null}, `match_stored`, шесть сумм (`Decimal`, в JSON — строки),
`warnings[]`. В свод SHALL входить только строки `group_type = COUNTERPARTY` с `data_type IN (BU, SUM)`; строки
MUST NOT агрегироваться между контрагентами. `counterparty_id` SHALL браться из исходных строк (непустое значение
последнего квартала; расхождение между кварталами — в `warnings`); при пустом — резолв по ИНН → имя → алиас на лету
по всему справочнику без policy, результат показывается с `match_stored = false` и MUST NOT записываться.
`counterparty_type`: `GSK` — `counterparty_id` ∈ `consolidation_member` версии; `GK` — `legal_entity.company_group_id`
= ГК консолидации; иначе `EXTERNAL` (в том числе при пустом `counterparty_id`).
#### Scenario: Признаки на фикстурах РБ
- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО РБ, ООО Пекарня Счастливочная}; строка Счастливочной
по 60 с контрагентом «Русский бисквит», ИНН 3528052718 смаплена по ИНН; строка РБ «Пекарня Счастливочная ООО» без ИНН
не сопоставлена; строка РБ с контрагентом ЧКФ (в ГК, не в ГСК)
- **THEN** первая — `GSK`, `match_kind = INN`, `match_stored = true`; вторая — `EXTERNAL`, `counterparty_id` null;
третья — `GK`
#### Scenario: Дорезолв на чтение
- **WHEN** у строки `counterparty_id` пуст, а после маппинга в справочнике появился алиас, по которому имя резолвится
однозначно
- **THEN** в ответе `counterparty_id` заполнен, `match_kind = ALIAS`, `match_stored = false`; в `osv_entry` значение
не изменилось
### Requirement: Страница свода и сводка
`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`,
`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`,
`counterparty_type`, `unmapped=true` (`counterparty_id` пуст после дорезолва), `match_kind` (повторяемый), `q`
(подстрока без регистра в `counterparty_name` или префикс `counterparty_inn`). `GET …/osv/summary` SHALL отдавать
`rows_total`, `unmapped_total`, `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} независимо от
`match_stored`), `documents_total` (привязанные ОСВ-документы) и `gaps[]` (`legal_entity_id`, `legal_entity_name`,
`account_code`, `missing_quarters`). Версия в любом статусе, включая `CANCELLED`, SHALL читаться.
#### Scenario: Только несопоставленные
- **WHEN** запрошено `GET …/osv?unmapped=true`
- **THEN** все строки ответа с `counterparty_id = null`, `total` = `summary.unmapped_total`
#### Scenario: Фильтр по способу
- **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS`
- **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total`
### Requirement: Кандидаты для сопоставления
`GET …/counterparty-candidates` (`CONSOLIDATION_VIEW`, policy) с параметрами `q`, `scope` ∈ {`group` (по умолчанию),
`all`}, `entry_id` SHALL возвращать до 100 ЮЛ: `scope=group` — ЮЛ периметра версии и остальные ЮЛ той же ГК, `q`
необязателен; `scope=all` — весь справочник без policy видимости, `q` SHALL быть не короче 3 символов (иначе 422
`QUERY_TOO_SHORT`). Поиск: ИНН по префиксу, имя и алиасы — подстрока без регистра. Кандидат: `id`, `inn`, `name`,
`company_group {id, name}`, `gsk {id, name}` / `gsz {id, name}` (текущее членство), `counterparty_type` относительно
версии, `recommended` — `normalize_alias(name)` или алиас совпали с `normalize_alias(counterparty_name)` строки
`entry_id`. Рекомендуемые — первыми, затем по имени.
#### Scenario: Рекомендуемая Счастливочная
- **WHEN** запрошены кандидаты для строки РБ «Пекарня Счастливочная ООО» без `q`
- **THEN** первой идёт ООО «Пекарня Счастливочная» с `recommended = true`, `counterparty_type = GSK`; ЧКФ в списке с
`recommended = false`, `counterparty_type = GK`
### Requirement: Ручное сопоставление контрагента
`POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя блокировка) с телом
`{legal_entity_id, move_alias = false}` SHALL в одной транзакции: проверить, что строка принадлежит ОСВ-документу,
привязанному к версии (иначе 404 `ENTRY_NOT_FOUND`), и что ЮЛ существует (без policy; иначе 404 `LEGAL_ENTITY_NOT_FOUND`);
вычислить `normalized = normalize_alias(counterparty_name)`: пустой → алиас не создаётся; алиас с таким `normalized`
у других ЮЛ и `move_alias = false` → 409 `ALIAS_TAKEN` с `owners[] {legal_entity_id, name}`; с `move_alias = true` →
их алиасы удаляются (`audit_event(LEGAL_ENTITY_CHANGE)` на каждый); у выбранного ЮЛ алиаса нет → создаётся
(`audit_event(LEGAL_ENTITY_CHANGE)`); строке — `counterparty_id`, `counterparty_match_kind = MANUAL`, `modified_by/at`
не трогаются (`counterparty_name/inn` неизменны); автоприменение — всем строкам ОСВ-документов, привязанных к версии, с
тем же `counterparty_name.strip()`, пустым `counterparty_id` и ИНН пустым или равным ИНН выбранного ЮЛ →
`counterparty_id`, `PROPAGATED`; один `audit_event(OSV_COUNTERPARTY_MAP, section = CONSOLIDATION, object = osv_entry,
consolidation_version_id)` с `before_value {counterparty_id, match_kind}`, `after_value {counterparty_id, match_kind,
propagated_entry_ids, alias_created, alias_moved_from}`. Уже связанную строку SHALL быть можно перевязать. Ответ —
`{row, propagated, alias_created, alias_moved_from}`. `DIRECTORY_DELETE` для переноса алиаса MUST NOT требоваться.
#### Scenario: Сопоставление из плана среза
- **WHEN** в версии РБ 2024 Q3 строка РБ «Пекарня Счастливочная ООО» (счёт 60) связана с ООО «Пекарня Счастливочная»
- **THEN** 200; у строки `counterparty_type = GSK`, `match_kind = MANUAL`; создан `legal_entity_alias` «Пекарня
Счастливочная ООО»; все остальные строки РБ с тем же именем во всех привязанных ОСВ (60, 62, 66 …) получили тот же
`counterparty_id` и `PROPAGATED`; `propagated` ≥ 1; `summary.unmapped_total` уменьшился на `1 + propagated`
#### Scenario: Алиас занят
- **WHEN** `normalized` имени строки уже есть алиасом у другого ЮЛ, `move_alias = false`
- **THEN** 409 `ALIAS_TAKEN` с `owners`; строка и алиасы не изменились
#### Scenario: Перенос алиаса
- **WHEN** тот же запрос с `move_alias = true`
- **THEN** 200; алиас у прежнего владельца удалён, у выбранного создан, `alias_moved_from` = прежний владелец; два
`audit_event(LEGAL_ENTITY_CHANGE)` и один `OSV_COUNTERPARTY_MAP`
#### Scenario: ИНН противоречит
- **WHEN** у другой строки с тем же именем `counterparty_inn` заполнен и не равен ИНН выбранного ЮЛ
- **THEN** она не получает `counterparty_id` при автоприменении
### Requirement: Экспорт xlsx
`GET …/osv/export` (`CONSOLIDATION_VIEW`, policy; фильтры как у `GET …/osv`, без пагинации) SHALL отдавать файл
`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` с `Content-Disposition: attachment;
filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx`. Лист «Сводная ОСВ»: строка
заголовков — Компания ГК, ИНН компании, Счёт, Период, Контрагент, ИНН контрагента, Сальдо начало Дт, Сальдо начало Кт,
Обороты Дт, Обороты Кт, Сальдо конец Дт, Сальдо конец Кт, Тип контрагента, Сопоставлено с, Способ сопоставления; суммы
— числовые ячейки с форматом `#,##0.00` в рублях без деления; порядок строк как у API. Лист «Пробелы»: Компания ГК,
Счёт, Нет ОСВ за кварталы; при отсутствии пробелов — одна ячейка «пробелов нет». Файл SHALL строиться в памяти без S3;
`audit_event(EXPORT, section = EXPORT, object = consolidation_version, after_value = {filters, rows})`.
#### Scenario: Экспорт читается обратно
- **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl`
- **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы»
— одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3`

View File

@ -0,0 +1,151 @@
# Spec Delta: consolidations-api
## Purpose
Консолидация ГК за период: создание, дерево версий, карточка версии, периметр, блокировка «взять в работу», статусы
Среза 4 и чек-лист комплекта документов (§9.1, §10 `05-domain-rules.md`; DBML `consolidation*`, `required_document_rule`;
макет `34`, `35`, `36`, `37`, `42`). Префикс всех маршрутов — `/api/v1`.
## ADDED Requirements
### Requirement: Создание консолидации
`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter}`
SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year, Q3 → 01.10.year,
Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`, `approach = BASIC`,
`goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя, `is_in_work = true`),
`consolidation_member` из текущих членов ГСК (`is_head_entity = false`), первую запись `consolidation_status_history`
(`from_status` NULL → `INITIATED`), подхват подтверждённых документов (см. `documents-api` «Подхват документов») и
`audit_event(CONSOLIDATION_CREATE)`; ответ 201 с карточкой версии. ГСК SHALL принадлежать ГК и иметь `kind = gsk`,
иначе 422 `RELATED_GROUP_MISMATCH`; членов меньше двух → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть
→ 409 `CONSOLIDATION_EXISTS` (в том числе при гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`);
`year` вне 2000..2100 → 422. Блокировка на создателя MUST NOT ставиться. `related_group_id` в `consolidation` SHALL
оставаться NULL (фиксируется при верификации, Срез 7).
#### Scenario: Консолидация РБ за Q3 2024
- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: ГСК из одного ЮЛ
- **WHEN** в ГСК один текущий член
- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы
#### Scenario: Повтор периода
- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна
- **THEN** 409 `CONSOLIDATION_EXISTS`
### Requirement: Список консолидаций ГК и карточка версии
`GET /company-groups/{id}/consolidations` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать периоды ГК (`consolidation_id`,
`year`, `quarter`, `reporting_date`) по убыванию года и квартала, в каждом — версии по убыванию `version_no`
(`id`, `version_no`, `status`, `scenario`, `approach`, `lock` — активная блокировка или null, `created_at`,
`members_count`); параметр `in_work_only=true` SHALL оставлять только версии со статусом не из
{`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}. `GET /consolidations/versions/{id}` SHALL отдавать карточку: поля
версии и консолидации, `company_group {id, name}`, инициатора и его офис, `lock`, `members[]` (ЮЛ: `id`, `name`, `inn`,
`is_head_entity`, `checklist[]` — ячейки по правилам подхода, см. «Чек-лист комплекта»), `history[]` (`from_status`,
`to_status`, `changed_by`, `changed_at`, `comment`). Консолидация ГК вне видимости и несуществующий идентификатор —
404 `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` с одинаковым телом.
#### Scenario: Дерево версий
- **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список
- **THEN** первым период 2024 Q3, затем 2024 Q1 с версиями `[2, 1]`; `members_count` у каждой версии
#### Scenario: Чужой филиал
- **WHEN** `ka_rf` другого филиала запрашивает карточку версии
- **THEN** 404 `VERSION_NOT_FOUND`, тело совпадает с ответом на случайный `uuid`
### Requirement: Периметр версии
`PUT /consolidations/versions/{id}/members` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя активная блокировка)
с телом `{legal_entity_ids: [...]}` SHALL заменить периметр версии: все ЮЛ SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем; меньше двух → 422
`PERIMETER_TOO_SMALL`; дубли в списке схлопываются. Для добавленных ЮЛ SHALL выполняться подхват подтверждённых
документов, для удалённых — отвязка их `consolidation_document`; `audit_event(MEMBERSHIP_CHANGE, section =
CONSOLIDATION, object = consolidation_version)` с `before_value`/`after_value` — полные списки ЮЛ. `related_group_member`
MUST NOT изменяться. Ответ — карточка версии.
#### Scenario: Добавление ЮЛ с подтверждёнными документами
- **WHEN** в периметр версии 2024 Q3 добавлено ЮЛ ГК, у которого подтверждена ОСВ 60 за 2024 Q3 (нарастающая)
- **THEN** 200; в `members` три ЮЛ; у нового ЮЛ ячейка ОСВ 60 `CONFIRMED`; `consolidation_document` на этот документ есть
#### Scenario: ЮЛ другой ГК
- **WHEN** в список попало ЮЛ, `company_group_id` которого не равен ГК консолидации
- **THEN** 422 `ENTITY_NOT_IN_GROUP`, периметр не изменился
#### Scenario: Без блокировки
- **WHEN** у версии нет активной блокировки, пользователь с `CONSOLIDATION_EDIT` меняет периметр
- **THEN** 409 `LOCK_REQUIRED`
### Requirement: Взять в работу, перехват, снятие
`POST /consolidations/versions/{id}/lock` (`CONSOLIDATION_EDIT`, policy; версия в любом статусе, кроме `CANCELLED` —
409 `VERSION_NOT_EDITABLE`) SHALL: при отсутствии активной блокировки создать `consolidation_lock(locked_by, locked_at)`
и `audit_event(CONSOLIDATION_LOCK)` → 201; при своей активной — вернуть её без изменений → 200; при чужой без
`takeover_reason` → 409 `LOCKED_BY_OTHER` с `locked_by`, `locked_by_name`, `locked_at` в `detail`; при чужой с
непустым `takeover_reason` (≤ 1000 символов) — закрыть прежнюю (`released_at = now`), создать новую с
`taken_over_from` и `takeover_reason`, `audit_event(CONSOLIDATION_LOCK_TAKEOVER, before = прежний владелец, after =
новый, comment = причина)` → 201. Два одновременных `POST` без активной блокировки → у одного 201, у другого 409
`LOCKED_BY_OTHER` (частичный UNIQUE). `DELETE /consolidations/versions/{id}/lock` SHALL снять свою активную
блокировку (`released_at = now`, `audit_event(CONSOLIDATION_LOCK, after_value.released = true)`) → 204; чужую — 409
`NOT_LOCK_OWNER`; без активной — 204. Блокировка MUST NOT истекать по времени (OQ-119).
#### Scenario: Перехват с причиной
- **WHEN** версию держит `ka_rf` А, `ka_rf` Б того же филиала вызывает `POST …/lock {takeover_reason: "Отпуск"}`
- **THEN** 201; у Б активная блокировка с `taken_over_from = A`; у А `released_at` заполнен;
`audit_event(CONSOLIDATION_LOCK_TAKEOVER)` с `comment = "Отпуск"`
#### Scenario: Перехват без причины
- **WHEN** Б вызывает `POST …/lock` без тела при активной блокировке А
- **THEN** 409 `LOCKED_BY_OTHER`, `detail.locked_by = A`
#### Scenario: Чужое снятие
- **WHEN** Б вызывает `DELETE …/lock` при активной блокировке А
- **THEN** 409 `NOT_LOCK_OWNER`, блокировка А активна
### Requirement: Статусы Среза 4
Версия SHALL менять статус только через сервис статусов, который пишет `consolidation_status_history(from, to,
changed_by, changed_at, comment)` и `audit_event(CONSOLIDATION_STATUS_CHANGE, before = from, after = to, comment)`.
Автоматические переходы: загрузка файла с `consolidation_version_id` (см. `primary-files`) — `INITIATED →
DOCUMENTS_PROCESSING`; маппинг документа `NEEDS_CHECK`, подходящего версии (см. `osv-result-mapping`) —
`DOCUMENTS_PROCESSING → DATA_CORRECTION`; подтверждение документа, привязанного к версии (см. `documents-api`) —
`INITIATED` или `DOCUMENTS_PROCESSING → DATA_CORRECTION`. Ручной `POST /consolidations/versions/{id}/status`
(`CONSOLIDATION_EDIT`, policy, своя блокировка) с телом `{to_status, comment}` SHALL допускать только: `CANCELLED` из
любого статуса «в работе» (`comment` обязателен, иначе 422 `COMMENT_REQUIRED`; `cancelled_reason = comment`,
`is_in_work = false`, активная блокировка снимается) и `INITIATED` из `DOCUMENTS_PROCESSING` / `DATA_CORRECTION`
(`comment` необязателен; `consolidation_document` сохраняются). Любой другой `to_status` → 409 `TRANSITION_NOT_ALLOWED`
с текущим и запрошенным статусами. Повтор того же статуса → 409 `TRANSITION_NOT_ALLOWED`. Статус «в работе» =
{`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}.
#### Scenario: Аннулирование
- **WHEN** владелец блокировки переводит версию `DATA_CORRECTION` в `CANCELLED` с комментарием
- **THEN** 200; `status = CANCELLED`, `cancelled_reason` = комментарий, `is_in_work = false`, блокировка снята; запись
истории `DATA_CORRECTION → CANCELLED` с комментарием; `audit_event(CONSOLIDATION_STATUS_CHANGE)`
#### Scenario: Переход Среза 7
- **WHEN** запрошен `to_status = CONSOLIDATION_RF`
- **THEN** 409 `TRANSITION_NOT_ALLOWED`, статус не изменился
#### Scenario: Аннулирование без комментария
- **WHEN** `to_status = CANCELLED`, `comment` пуст
- **THEN** 422 `COMMENT_REQUIRED`
### Requirement: Чек-лист комплекта
`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила
`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`,
затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{status,
missing_quarters, document_ids}`: `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными документами (ОСВ: по
правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии); `NEEDS_CHECK` / `RECOGNIZED` —
есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии (приоритет `NEEDS_CHECK`);
`PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по `uploaded_at` файл ЮЛ в `ERROR`;
иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при частичном покрытии. Правило ОСВ
SHALL закрываться только документом с `osv_account_code`, равным `account` правила (OQ-04). Те же ячейки SHALL
отдаваться в карточке версии у каждого члена.
#### Scenario: Пробел Q2
- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3
- **THEN** ячейка ОСВ 60: `status = MISSING`, `missing_quarters = ["Q2"]`, `document_ids` — два документа
#### Scenario: Субсчёт не закрывает правило
- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии
- **THEN** ячейка ОСВ 58 — `MISSING`
#### Scenario: Файл в обработке
- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет
- **THEN** все ячейки ЮЛ — `PROCESSING`

View File

@ -0,0 +1,47 @@
# Spec Delta: data-model
## RENAMED Requirements
- FROM: `### Requirement: Схема БД равна DBML v5.15`
- TO: `### Requirement: Схема БД равна DBML v5.16`
## MODIFIED Requirements
### Requirement: Схема БД равна DBML v5.16
ORM-метаданные SHALL содержать ровно 43 таблицы и 28 enum'ов из `../docs/model/data-model-v5.16.dbml` в схеме `dkb`;
имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения
по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member`
`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE
`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`.
`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке).
`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`; `osv_data_type` SHALL содержать `NU` (после `BU`).
Enum `counterparty_match_kind` SHALL содержать `INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`; колонка
`osv_entry.counterparty_match_kind` (nullable) SHALL быть NULL тогда и только тогда, когда `counterparty_id` NULL
(проверяет ядро при записи). `app_user.email` SHALL храниться в нижнем регистре. Миграция `0004_dbml_v5_16` SHALL
создавать enum и колонку и заполнять её по уже смапленным строкам (ИНН строки без пробелов = `legal_entity.inn` → `INN`;
`trim(counterparty_name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS`); откат SHALL удалять
колонку и enum. Поля, отсутствующие в DBML, MUST NOT существовать.
#### Scenario: Сверка ORM с DBML
- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.16.dbml`
- **THEN** вывод пуст и код возврата 0
#### Scenario: Лишняя колонка в ORM
- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая)
- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1
#### Scenario: Расхождение типа или nullability
- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL`
- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1
#### Scenario: ЮЛ одновременно в ГСК и ГСЗ
- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz`
- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс
#### Scenario: Строка налогового учёта
- **WHEN** в `osv_entry` вставляется строка с `data_type = 'NU'`
- **THEN** вставка проходит
#### Scenario: Backfill при миграции
- **WHEN** до миграции 0004 есть строки с `counterparty_id`, найденным по ИНН, по имени и по алиасу, и строка без `counterparty_id`
- **THEN** после `alembic upgrade head` у них `counterparty_match_kind` = `INN`, `NAME`, `ALIAS` и NULL соответственно;
`alembic downgrade 0003` удаляет колонку и enum

View File

@ -0,0 +1,25 @@
# Spec Delta: directory-policy
## ADDED Requirements
### Requirement: Видимость консолидаций и редактируемость версии
Консолидация и её версии SHALL быть видимы тем же пользователям, что и её ГК (`company_group`); чужие и
несуществующие — 404 с одинаковым телом (`CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`). Правка версии (периметр,
ручная смена статуса, сопоставление контрагента) SHALL проходить проверки в порядке: право → видимость → статус
версии ∈ {`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`} (иначе 409 `VERSION_NOT_EDITABLE`) → активная
`consolidation_lock` есть (иначе 409 `LOCK_REQUIRED`) и принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с
владельцем). Проверка реализуется чистой функцией в `domain/policy.py` без обращения к коду роли. Чтение (карточка,
свод, чек-лист, экспорт, кандидаты) и загрузка/подтверждение документов от статуса блокировки MUST NOT зависеть.
Зоны ответственности не учитываются (ASSUMPTION(OQ-86)).
#### Scenario: Чужая блокировка
- **WHEN** версию держит А, Б с `CONSOLIDATION_EDIT` вызывает `PUT …/members`
- **THEN** 409 `LOCKED_BY_OTHER`, `detail.locked_by = A`
#### Scenario: Аннулированная версия
- **WHEN** версия в `CANCELLED`, владелец прежней блокировки вызывает `POST …/osv/entries/{id}/counterparty`
- **THEN** 409 `VERSION_NOT_EDITABLE`
#### Scenario: Куратор ГО читает
- **WHEN** `curator_go` (только `CONSOLIDATION_VIEW`) запрашивает свод версии любого филиала
- **THEN** 200

View File

@ -0,0 +1,59 @@
# Spec Delta: documents-api
## MODIFIED Requirements
### Requirement: Подтверждение документа
`POST /api/v1/documents/{document_id}/confirm` (`DOCUMENT_EDIT`, policy) SHALL переводить документ из `RECOGNIZED`
или `NEEDS_CHECK` в `CONFIRMED` (`confirmed_by`, `confirmed_at`) с `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS)`;
из `CONFIRMED` повторно — 409 `ALREADY_CONFIRMED`; документ файла `SUPERSEDED` — 409 `FILE_SUPERSEDED`; документ в любом
другом статусе (например, `ERROR`) — 409 `DOCUMENT_NOT_CONFIRMABLE`. Если совпали несколько условий, приоритет у
`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ → версии консолидации (по возрастанию
`id`)» и перечитывает их под `FOR UPDATE`; документ или файл, исчезнувшие за время ожидания блокировки, — 404 `NOT_FOUND`.
В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`,
`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии,
`period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал
версии; false → `period_quarter` ≤ квартала версии). Привязанный ранее документ того же ЮЛ с той же тройкой
(`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая
привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` =
подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться.
#### Scenario: Причина ошибки маппинга в карточке файла
- **WHEN** файл в `ERROR` после неудачного маппинга (задание `done`, `error_code` пуст)
- **THEN** `job.mapping_error` содержит причину, `job.error_code` пуст
#### Scenario: Документ не подтверждается
- **WHEN** подтверждается документ в статусе `ERROR`
- **THEN** 409 `DOCUMENT_NOT_CONFIRMABLE`, статус не изменился
#### Scenario: Подтверждение
- **WHEN** `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала
- **THEN** `status = CONFIRMED`, `confirmed_by` = пользователь, `audit_event(DOCUMENT_CONFIRM)` с `before_value.status = NEEDS_CHECK`
#### Scenario: Привязка к версии и переход статуса
- **WHEN** версия ГК 2024 Q3 в `INITIATED` с ЮЛ в периметре; подтверждается квартальная ОСВ 60 этого ЮЛ за 2024 Q2
- **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории
`INITIATED → DATA_CORRECTION` с `changed_by` = пользователь
#### Scenario: Не подходит по периоду
- **WHEN** подтверждается нарастающая ОСВ 60 за 2024 Q2 при версии за 2024 Q3
- **THEN** привязки нет, статус версии не изменился
#### Scenario: Версия в CANCELLED
- **WHEN** подходящий документ подтверждается при версии `CANCELLED`
- **THEN** привязки нет
## ADDED Requirements
### Requirement: Подхват документов при создании версии и расширении периметра
При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из
незаменённых файлов, подходящие версии (условия — как при подтверждении); на одну тройку (`document_type`,
`osv_account_code`, `period_quarter`) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL
снимать привязку всех его документов в этой версии. Подхват и отвязка статус версии MUST NOT менять.
#### Scenario: Фикстуры, подтверждённые до консолидации
- **WHEN** ОСВ 60 РБ за Q1, Q2, Q3 2024 подтверждены, затем создана консолидация ГК за 2024 Q3
- **THEN** три `consolidation_document` на эти документы; в чек-листе ячейка ОСВ 60 РБ — `CONFIRMED`; версия в `INITIATED`
#### Scenario: Две подтверждённые версии одного документа
- **WHEN** у ЮЛ два незаменённых файла с `CONFIRMED` ОСВ 62 за 2024 Q3 (разные `locator`), создана версия 2024 Q3
- **THEN** привязан документ с более поздним `confirmed_at`

View File

@ -0,0 +1,33 @@
# Spec Delta: osv-result-mapping
## MODIFIED Requirements
### Requirement: Резолв контрагента
Для строк `group_type = COUNTERPARTY` с `data_type ∈ {BU, SUM}` ядро SHALL искать `counterparty_id` по §3 п. 4: по
`counterparty_inn` в `legal_entity.inn`; иначе по точному `counterparty_name` в `legal_entity.name`; иначе по
`normalize_alias(counterparty_name)` среди нормализованных имён ЮЛ и `legal_entity_alias.normalized_alias`; больше
одного кандидата на шаге → `NULL`. Резолв SHALL выполняться по всему справочнику без фильтра видимости. Вместе с
`counterparty_id` SHALL записываться `counterparty_match_kind`: `INN`, `NAME` или `ALIAS` по шагу, на котором нашёлся
единственный кандидат (нормализованное имя ЮЛ и алиас — оба `ALIAS`); при `NULL` — NULL.
#### Scenario: По ИНН, по алиасу, неоднозначно
- **WHEN** в справочнике есть ЮЛ с ИНН `3528052718`, ЮЛ с алиасом `русский бисквит`, и два ЮЛ с одинаковым нормализованным именем `альфа`
- **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по
алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL`
## ADDED Requirements
### Requirement: Документ NEEDS_CHECK переводит версию в корректировку
После фиксации документов файла цикл маппинга SHALL для каждой версии в `DOCUMENTS_PROCESSING`, в периметр которой
входит ЮЛ файла, проверить документы файла: если среди них есть документ `NEEDS_CHECK`, подходящий версии по периоду
(правила привязки `documents-api`), версия SHALL перейти в `DATA_CORRECTION` с `changed_by = stored_file.uploaded_by`
в той же транзакции маппинга (версии блокируются `FOR UPDATE` по `id` после файла). Если `uploaded_by` NULL, переход
SHALL пропускаться с записью в лог. Привязка `consolidation_document` при маппинге MUST NOT создаваться.
#### Scenario: Предупреждение маппинга двигает статус
- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK`
- **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет
#### Scenario: Документ RECOGNIZED
- **WHEN** смапплен файл с единственным документом `RECOGNIZED`
- **THEN** статус версии не изменился

View File

@ -0,0 +1,69 @@
# Spec Delta: primary-files
## MODIFIED Requirements
### Requirement: Загрузка файлов организации
`POST /api/v1/legal-entities/{legal_entity_id}/files` (multipart, одно или несколько полей `files`; право
`DOCUMENT_EDIT`; ЮЛ в области видимости пользователя, иначе 404) SHALL для каждого файла в отдельной транзакции:
определить тип по расширению и сверить с сигнатурой содержимого (`xls` — BIFF8, `xlsx`/`xlsb` — zip, `pdf`, `png`,
`jpg`); отвергнуть файл больше `UPLOAD_MAX_BYTES` (50 МиБ) или неподдерживаемого типа; вычислить sha256; создать
`s3_object` (`bucket_role = uploads`, ключ `uploads/{file_id}/{job_id}/{безопасное имя}`, `sha256`, `size_bytes`,
`content_type`, `created_by = user:{id}`), положить объект в S3, создать `stored_file` (`status = UPLOADED`,
`version_no = 1`, `uploaded_by`, `uploaded_at`, `content_hash`, `original_name`) и `recognition_job` (`status =
queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `image`; `max_attempts` 3 / 1), установить
`stored_file.current_recognition_job_id`, записать `audit_event(FILE_UPLOAD, PRIMARY_DOCUMENTS)`. Ответ SHALL
перечислять принятые файлы (`accepted[]` с `file_id`, `job_id`, `name`) и отклонённые (`rejected[]` с `name`, `code`,
`message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT
запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`.
Необязательный query-параметр `consolidation_version_id` SHALL проверяться до приёма файлов: версия видима (иначе 404
`VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422
`ENTITY_NOT_IN_PERIMETER`); если принят хотя бы один файл и версия в `INITIATED`, она SHALL перейти в
`DOCUMENTS_PROCESSING` (`changed_by` = загрузивший) в отдельной транзакции после приёма файлов. Блокировка версии
для загрузки MUST NOT требоваться.
#### Scenario: Реальная ОСВ принята и ушла в outbox
- **WHEN** `ka_rf` загружает `rb/2024-FY/ОСВ_66 за 2024.xls` для ЮЛ своего филиала
- **THEN** 201, `accepted[0].file_id` существует; `stored_file.status = UPLOADED`, `file_type = XLS`, `content_hash` =
sha256 файла; `recognition_job.status = queued`, `pipeline = tabular`; объект в S3 по ключу `uploads/{file_id}/{job_id}/ОСВ_66 за 2024.xls`;
публикатор outbox публикует запрос с этим ключом
#### Scenario: Смесь принятых и отклонённых
- **WHEN** в одном запросе xlsx, файл `.docx` и файл 51 МиБ
- **THEN** 201, `accepted` — один, `rejected` — два с кодами `UNSUPPORTED_FILE` и `FILE_TOO_LARGE`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** загружается файл `osv.xlsx`, байты которого — BIFF8
- **THEN** файл отклонён с кодом `UNSUPPORTED_FILE`
#### Scenario: Чужое ЮЛ
- **WHEN** `ka_rf` загружает файл для ЮЛ другого филиала
- **THEN** 404, объектов и записей нет
#### Scenario: Загрузка в контексте версии
- **WHEN** версия в `INITIATED`, ЮЛ в периметре, загружен один принятый файл с `?consolidation_version_id=`
- **THEN** 201; версия в `DOCUMENTS_PROCESSING`, запись истории с `changed_by` = пользователь
#### Scenario: ЮЛ вне периметра
- **WHEN** `consolidation_version_id` указан, ЮЛ не в периметре версии
- **THEN** 422 `ENTITY_NOT_IN_PERIMETER`, файлы не приняты
### Requirement: Автозамена прежних версий
После успешного маппинга файла ядро SHALL найти другие файлы того же ЮЛ со статусом не `SUPERSEDED`, у которых есть
документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и перевести их в
`SUPERSEDED` (`superseded_by_file_id` = новый файл, `updated_at`); новый файл получает `version_no` = максимум среди
заменённых + 1. Документы и строки заменённых файлов SHALL сохраняться (история), но по умолчанию не показываться.
Привязки `consolidation_document` документов заменённых файлов к версиям «в работе» SHALL удаляться в той же
транзакции (версии блокируются `FOR UPDATE` по возрастанию `id` после старых файлов); привязки к версиям в других
статусах не трогаются; статус версии не меняется. Аудит `FILE_REPLACE` (`object_id` заменённого, `after_value` с
новым файлом и `unbound_version_ids`, в `description` — перечень заменённых документов и признак `confirmed`, если
заменён документ `CONFIRMED`). Решение владельца 05.10.2026: замена происходит и для `CONFIRMED`.
#### Scenario: Новая ОСВ 60 за 1 кв. 2025
- **WHEN** у ЮЛ есть `PROCESSED` файл с документом OSV 60 / 2025 / Q1 (`CONFIRMED`), загружен и смапплен новый файл с таким же документом
- **THEN** прежний файл — `SUPERSEDED`, `superseded_by_file_id` = новый, у нового `version_no = 2`; `audit_event(FILE_REPLACE)`
с `description`, содержащим `confirmed=true`; список файлов ЮЛ по умолчанию показывает только новый
#### Scenario: Заменённый документ был в версии
- **WHEN** заменённый `CONFIRMED` документ ОСВ 60 за 2024 Q2 был привязан к версии 2024 Q3 в `DATA_CORRECTION`
- **THEN** привязка удалена; в чек-листе ячейка ОСВ 60 показывает `missing_quarters = ["Q2"]` (или `NEEDS_CHECK`
по новому документу), пока новый документ не подтверждён; статус версии прежний

View File

@ -0,0 +1,64 @@
# Tasks: Срез 4 — arm-core
Каждая задача ≤ половины дня, начинается с тестов, заканчивается зелёным `make check` и интеграционными тестами своей
области. Допущения в коде — `# ASSUMPTION(OQ-nn)`. DBML v5.16 — `../docs/model/data-model-v5.16.dbml` (ветка
`feat/19-slice-4-consolidation` в `.wt/slice-4/docs`). Детальный план с кодом — `docs/superpowers/plans/` (writing-plans).
## 1. Модель
- [ ] 1.1 **DBML v5.16, миграция 0004, `match_kind` в маппинге.** `CounterpartyMatchKind` enum, колонка
`OsvEntry.counterparty_match_kind`; миграция `0004_dbml_v5_16` (enum, колонка, backfill SQL, downgrade); `DBML_VERSION`,
`Makefile`, `dbml_parity`, `test_migrations` (HEAD), `test_enums`, `test_check_dbml_script` (фиктивная 5.17);
`CounterpartyIndex.resolve` → `Resolution(entity_id, kind) | None`; `result_mapping._with_counterparty` пишет kind;
`EntryOut.match_kind`. Тесты: backfill на трёх строках, golden-маппинг с kind, `test_counterparty_resolve`.
- [ ] 1.2 **Фабрики и репозиторий.** `tests/factories.py`: `make_consolidation(session, *, group, year, quarter, members, status)`,
`make_lock`, `make_confirmed_osv(entity, account, year, quarter, cumulative, rows)`; `repositories/consolidations.py`:
`get_version(scope)`, `list_periods(group)`, `lock_version`, `lock_versions_for_entity(entity_id, statuses)`, `active_lock`,
`members`, `bound_documents`, `load_osv_rows`, `history`; `CommittedJobs.cleanup()` знает таблицы консолидации.
## 2. Версии, периметр, блокировка, статусы
- [ ] 2.1 **Создание, список, карточка.** `schemas/consolidations.py` (D-16), `services/consolidations.py:create/list_periods/card`,
`domain/consolidation_periods.py:reporting_date`, эндпоинты `POST /consolidations`, `GET /company-groups/{id}/consolidations`,
`GET /consolidations/versions/{id}`, роутер. Тесты: сценарии `consolidations-api` «Создание», «Список и карточка», policy 404.
- [ ] 2.2 **Статусы и политика.** `domain/policy.py:IN_WORK_STATUSES, ensure_version_editable`; `services/consolidation_status.py:
transition(version, to, by, comment, *, manual)` с таблицей Среза 4; `POST …/status`. Тесты: `CANCELLED` с/без комментария,
возврат в `INITIATED`, `TRANSITION_NOT_ALLOWED`, `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`.
- [ ] 2.3 **Блокировка.** `services/consolidation_locks.py:take/release`, `POST/DELETE …/lock`, `IntegrityError` → `LOCKED_BY_OTHER`.
Тесты: сценарии «Взять в работу», гонка двух `POST` (`test_api_constraint_races`), `CANCELLED` снимает блокировку.
- [ ] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии».
## 3. Привязка документов и чек-лист
- [ ] 3.1 **Привязка.** `services/consolidation_documents.py:fits(document, version)`, `bind_confirmed(version, entity_ids)`,
`unbind_entity`, `on_document_confirmed(document, user)`, `on_file_superseded(file, documents)`; правки
`DocumentService.confirm` (версии `FOR UPDATE` после документа), `supersede_previous` (`unbound_version_ids` в аудите).
Тесты: сценарии `documents-api` (привязка, период, `CANCELLED`, подхват, две версии документа), `primary-files`
«Заменённый документ был в версии», порядок блокировок (подтверждение ∥ правка периметра).
- [ ] 3.2 **Статусы по событиям файлов.** `upload.py`: `consolidation_version_id` (проверки, переход после приёма);
`result_mapping`: хук `on_documents_mapped` (NEEDS_CHECK → `DATA_CORRECTION`, `uploaded_by`, пропуск при NULL).
Тесты: сценарии `primary-files` «Загрузка в контексте версии», `osv-result-mapping` «Предупреждение маппинга».
- [ ] 3.3 **Чек-лист.** `domain/checklist.py:build(rules, members, documents, files)` (чистая), `GET …/checklist`, ячейки в карточке.
Тесты: пробел Q2, субсчёт, `PROCESSING`, `ERROR`, ББ/ОФР за квартал версии.
## 4. Сводная ОСВ
- [ ] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py:
build(...)` (склейка по ключу, нули, дубли, `entry_ids`, `warnings`, дорезолв, признак, фильтры, порядок). Тесты без БД:
все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`.
- [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на
PostgreSQL с тремя кварталами, `unmapped`, `match_kind`, `q`, чтение `CANCELLED`, `curator_go` читает.
- [ ] 4.3 **Кандидаты и сопоставление.** `GET …/counterparty-candidates`, `POST …/osv/entries/{id}/counterparty`
(`services/counterparty_mapping.py`: алиас, `ALIAS_TAKEN`, перенос, `MANUAL`/`PROPAGATED`, ИНН-противоречие, аудит).
Тесты: все сценарии «Кандидаты», «Ручное сопоставление», блокировка/статус.
- [ ] 4.4 **Экспорт.** `services/osv_export.py` (openpyxl, два листа, `BytesIO`), `GET …/osv/export`, `Content-Disposition`,
аудит `EXPORT`. Тест: файл читается `openpyxl`, суммы равны API, лист «Пробелы».
## 5. Сквозной тест и документы
- [ ] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и
Счастливочной за Q1–Q3 2024 → подтверждение → консолидация 2024 Q3 → свод: счета 60/62 у обеих и 76 у Счастливочной,
остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем;
экспорт открывается.
- [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md
(новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9).