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:
parent
714832efd9
commit
6e5ab834ec
197
openspec/changes/slice-4-consolidation/design.md
Normal file
197
openspec/changes/slice-4-consolidation/design.md
Normal 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) считаются нарастающими при привязке | формы всегда за период с начала года | нет |
|
||||
87
openspec/changes/slice-4-consolidation/proposal.md
Normal file
87
openspec/changes/slice-4-consolidation/proposal.md
Normal 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).
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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`
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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`
|
||||
@ -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** статус версии не изменился
|
||||
@ -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`
|
||||
по новому документу), пока новый документ не подтверждён; статус версии прежний
|
||||
64
openspec/changes/slice-4-consolidation/tasks.md
Normal file
64
openspec/changes/slice-4-consolidation/tasks.md
Normal 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).
|
||||
Loading…
x
Reference in New Issue
Block a user