From 6e5ab834ece04cac23239406744fbc36a673d9ca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 02:52:23 +0400 Subject: [PATCH 01/27] =?UTF-8?q?spec(slice-4):=20OpenSpec=20change=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=E2=80=94=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?,=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80,=20=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=BD=D0=B0=D1=8F=20=D0=9E=D0=A1=D0=92,=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../changes/slice-4-consolidation/design.md | 197 ++++++++++++++++++ .../changes/slice-4-consolidation/proposal.md | 87 ++++++++ .../specs/audit-trail/spec.md | 30 +++ .../specs/consolidated-osv/spec.md | 145 +++++++++++++ .../specs/consolidations-api/spec.md | 151 ++++++++++++++ .../specs/data-model/spec.md | 47 +++++ .../specs/directory-policy/spec.md | 25 +++ .../specs/documents-api/spec.md | 59 ++++++ .../specs/osv-result-mapping/spec.md | 33 +++ .../specs/primary-files/spec.md | 69 ++++++ .../changes/slice-4-consolidation/tasks.md | 64 ++++++ 11 files changed, 907 insertions(+) create mode 100644 openspec/changes/slice-4-consolidation/design.md create mode 100644 openspec/changes/slice-4-consolidation/proposal.md create mode 100644 openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/data-model/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/documents-api/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md create mode 100644 openspec/changes/slice-4-consolidation/specs/primary-files/spec.md create mode 100644 openspec/changes/slice-4-consolidation/tasks.md diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md new file mode 100644 index 0000000..fd1926d --- /dev/null +++ b/openspec/changes/slice-4-consolidation/design.md @@ -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.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) считаются нарастающими при привязке | формы всегда за период с начала года | нет | diff --git a/openspec/changes/slice-4-consolidation/proposal.md b/openspec/changes/slice-4-consolidation/proposal.md new file mode 100644 index 0000000..427bbaa --- /dev/null +++ b/openspec/changes/slice-4-consolidation/proposal.md @@ -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). diff --git a/openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md b/openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md new file mode 100644 index 0000000..9b3fee1 --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/audit-trail/spec.md @@ -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` diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..cc3545e --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -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"`), `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.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` diff --git a/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md new file mode 100644 index 0000000..52c0fe3 --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md @@ -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` diff --git a/openspec/changes/slice-4-consolidation/specs/data-model/spec.md b/openspec/changes/slice-4-consolidation/specs/data-model/spec.md new file mode 100644 index 0000000..2620051 --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/data-model/spec.md @@ -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 diff --git a/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md b/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md new file mode 100644 index 0000000..2cd6e41 --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md @@ -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 diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md new file mode 100644 index 0000000..5e2f6e4 --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -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` diff --git a/openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md b/openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md new file mode 100644 index 0000000..97d128d --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/osv-result-mapping/spec.md @@ -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** статус версии не изменился diff --git a/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md b/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md new file mode 100644 index 0000000..2be247a --- /dev/null +++ b/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md @@ -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` + по новому документу), пока новый документ не подтверждён; статус версии прежний diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md new file mode 100644 index 0000000..9fd140a --- /dev/null +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -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). -- 2.47.2 From 52ae1b937d65af0659dfca674dcb5b969e42dd86 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:04:17 +0400 Subject: [PATCH 02/27] =?UTF-8?q?plan(slice-4):=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=E2=80=94=2014=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B5=20slice-4-consolidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../plans/2026-10-06-slice-4-consolidation.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-4-consolidation.md diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md new file mode 100644 index 0000000..0a2a3be --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -0,0 +1,277 @@ +# Срез 4 — консолидация, периметр, блокировка, привязка документов, сводная ОСВ, экспорт (arm-core) — план исполнения + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/18-slice-4-consolidation` (worktree `.wt/slice-4/arm-core`). **Issue:** RSHB/arm-core#18. **PR:** #19 (открыт, спека). +**Соседний worktree:** docs `.wt/slice-4/docs` (ветка `feat/19-slice-4-consolidation`, issue #19, PR #20) — задачи docs-change 1 (DBML v5.16) выполняется в Task 1, задачи 2–4 — в Task 14 этого плана. `../fixtures` — симлинк, только чтение (golden-снимки `golden/expected_osv_parse/{rb,schastl}/2024-Q{1,2,3}/*.json`). Compose `arm-ka` поднят (PostgreSQL/NATS/MinIO) — не останавливать. +**Spec:** `openspec/changes/slice-4-consolidation/` — proposal.md, design.md (D-1…D-16, решения без обсуждения #1–#10), specs/{consolidations-api,consolidated-osv,data-model,documents-api,primary-files,osv-result-mapping,directory-policy,audit-trail}/spec.md. Правила: `../docs/context/05-domain-rules.md` §2, §3, §9.1, §9.3, §10; `04-data-model-guide.md` «что не хранится». Спека — единственный источник истины при рулингах; план сгенерирован из `tasks.md` change'а (номера в скобках). + +**Goal:** аналитик создаёт консолидацию ГК за квартал, берёт её в работу, подтверждённые ОСВ компаний периметра автоматически попадают в версию, сводная ОСВ за период строится как чистая функция с пробелами и признаком контрагента, несопоставленные контрагенты связываются вручную с автоприменением, свод выгружается в xlsx. + +**Architecture:** новый пакет `api/endpoints/consolidations/` → сервисы `services/consolidations.py` (создание, список, карточка, периметр), `consolidation_status.py` (переходы), `consolidation_locks.py`, `consolidation_documents.py` (привязка/отвязка/подхват), `consolidated_osv.py` (страница, сводка, кандидаты), `counterparty_mapping.py`, `osv_export.py`; репозиторий `repositories/consolidations.py`; чистый домен `domain/consolidation_periods.py` (reporting_date, выбор источников), `domain/consolidated_osv.py` (склейка, признак, фильтры), `domain/checklist.py`; policy `domain/policy.py` (редактируемость). Хуки в `documents.confirm`, `result_mapping` (kind + статус), `supersede_previous`, `upload`. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, pydantic v2, openpyxl 3.1 (уже в `requirements.txt`), pytest-asyncio, httpx. + +## Global Constraints + +- **DBML v5.16** (`../docs/model/data-model-v5.16.dbml`, 43 таблицы, 28 enum): + enum `counterparty_match_kind` (`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`), + `osv_entry.counterparty_match_kind` (nullable; NULL ⇔ `counterparty_id` NULL — проверяет код). Полей вне DBML нет. `make dbml-check` — критерий Task 1 и регрессия в каждой задаче. +- **Производные не хранятся** (DECISION-5): свод, пробелы, признак, дорезолв, чек-лист — функции в `src/domain/` без ORM; в БД пишутся только `consolidation_*`, `osv_entry.counterparty_id/match_kind`, `legal_entity_alias`, история, аудит. +- **Статус «в работе»** = {`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`}. Переходы только через `consolidation_status.transition(...)` (история + `CONSOLIDATION_STATUS_CHANGE`); ручные — только `CANCELLED` (комментарий обязателен, снимает блокировку, `is_in_work = false`) и возврат в `INITIATED`; остальное — 409 `TRANSITION_NOT_ALLOWED`. +- **Правка версии** (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`): право → видимость (ГК через `company_groups_visible`) → `ensure_version_editable` (409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`). Чтение, загрузка и подтверждение от блокировки не зависят. +- **Подходящесть документа версии** (D-5): `legal_entity_id ∈ members`, `period_year = year`, `period_quarter` не NULL; `is_cumulative` true/NULL → `period_quarter = quarter`; false → `period_quarter ≤ quarter`. Привязываются только `CONFIRMED` незаменённых файлов. +- **Порядок блокировок строк**: файл → документ → версии (по `id`); правка версии — `consolidation_version FOR UPDATE` первой; автозамена — advisory ЮЛ → старые файлы → версии. +- **Сессии** на маршрутах — только `SessionDep`; `audit_event` — только `AuditService.record`/`RequestAudit.record`, всегда с `consolidation_version_id` (для событий консолидации); `DomainError` — `{"detail": {"code", "message", …extra}}`. +- **Деньги** — `Decimal`/`Numeric(18,2)`; в JSON — строки (pydantic `Decimal`); в xlsx — числовые ячейки `#,##0.00`. +- **Линтеры**: ruff (120, E,F,I,UP,B,PL — константы вместо магических чисел, `# noqa: PLR0913 - причина`), mypy (strict-флаги для `src.domain.*`, `src.models.*`). Тесты: маркеры `integration`, `fixtures`. Команды — из `api/` (`make` из корня репозитория). +- **Коммиты** — русский, по задаче; трейлеры `Co-Authored-By: Claude <модель исполнителя> ` и `Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2`. Не пушить, не создавать PR, не `openspec archive`, `openspec/specs/` не трогать. Коммиты в `../docs` — в его worktree (`.wt/slice-4/docs`), те же правила. +- Допущения в коде: `# ASSUMPTION(OQ-40)` (reporting_date), `# ASSUMPTION(OQ-86)` (ЗО), `# ASSUMPTION(OQ-119)` (блокировка без истечения), `# ASSUMPTION(OQ-120)` (`period_check` не пишется), `# ASSUMPTION(OQ-122)` (`PROCESSING` по всем столбцам). + +## Review Focus + +1. **Один документ в двух версиях** (две консолидации ГК за Q2 и Q3 одного года, квартальная ОСВ Q1 подходит обеим): подтверждение привязывает к обеим и двигает обе; ручное сопоставление в одной меняет `osv_entry` для обеих (OQ-123) — Task 6: тест «две версии», Task 11: тест «строка видна во второй версии с `MANUAL`». +2. **Пара (ЮЛ, счёт) с нарастающим документом за Q2 при версии за Q3** — не подходит, не привязывается, не источник; с квартальными Q1–Q3 рядом — нарастающий за Q3 побеждает — Task 9: тесты. +3. **Ручное сопоставление строки, у которой имя из одних ОПФ** (`normalize_alias` пуст): алиас не создаётся, строка связывается, автоприменение по `strip()` имени всё равно работает — Task 11: тест. +4. **Перехват блокировки во время правки периметра владельцем** (две транзакции: `PUT …/members` под `FOR UPDATE` версии и `POST …/lock {takeover_reason}`): перехват ждёт блокировку версии, после неё правка владельца уже зафиксирована, его следующая правка — 409 `LOCKED_BY_OTHER` — Task 4: тест на двух сессиях (`committed_sessionmaker`). +5. **Экспорт с фильтром `unmapped=true`, но после дорезолва все сопоставлены** — пустой лист с заголовком, лист «Пробелы» заполнен, `audit EXPORT.rows = 0` — Task 12: тест. + +--- + +## Задачи + +### Task 1: [1.1] DBML v5.16, миграция 0004, `counterparty_match_kind` в маппинге + +**Files:** +- Create (worktree docs): `model/data-model-v5.16.dbml` (копия v5.15 + блок changelog «v5.16 (Срез 4, решения владельца 06.10.2026)»: 1. enum `counterparty_match_kind` + колонка `osv_entry.counterparty_match_kind` с note «NULL ⇔ counterparty_id NULL; INN/NAME/ALIAS — шаг автоматического резолва при маппинге; MANUAL — ручное сопоставление; PROPAGATED — автоприменение по имени в версии (DOC-221278 п. 12–13)»; 2. семантика `consolidation_document`: точки привязки/отвязки (D-5); 3. `consolidation_lock` без истечения (OQ-119)), `model/entities-v5.16.md` (копия v5.15 + «Что изменилось в v5.16»), правки `context/04-data-model-guide.md` (DECISION-5: «способ сопоставления хранится; пробелы и свод — нет»), `CLAUDE.md`/`repo-templates/arm-core.CLAUDE.md` → v5.16; `openspec/changes/slice-4-consolidation/tasks.md` 1 → `[x]`; коммит в docs +- Modify: `api/src/models/enums.py` (`CounterpartyMatchKind(StrEnum)`: `INN, NAME, ALIAS, MANUAL, PROPAGATED`), `api/src/models/osv.py` (`counterparty_match_kind: Mapped[CounterpartyMatchKind | None] = mapped_column(pg_enum("counterparty_match_kind"))` после `counterparty_id`), `api/src/models/__init__.py` (`DBML_VERSION = "5.16"`), `Makefile` (`DBML ?= ../../docs/model/data-model-v5.16.dbml`), `api/scripts/dbml_parity.py` (`DBML_FILE_NAME`), `openspec/config.yaml` (v5.16 в context), `api/tests/test_migrations.py` (`HEAD = "0004_dbml_v5_16"`), `api/tests/test_enums.py` (28 enum, 5 значений), `api/tests/test_check_dbml_script.py` (фиктивная `v5.17`), `api/src/repositories/counterparties.py` (`resolve` → `Resolution | None`), `api/src/services/result_mapping.py` (`_with_counterparty` пишет `counterparty_id` и `counterparty_match_kind`), `api/src/domain/osv_mapping.py` (`EntryDraft.counterparty_match_kind: CounterpartyMatchKind | None = None`; `_entry_values` переносит), `api/src/schemas/documents.py` (`EntryOut.match_kind: CounterpartyMatchKind | None`), `api/tests/factories.py` (`make_osv_entry` принимает `counterparty_match_kind`), `api/tests/test_counterparty_resolve.py`, `api/tests/test_result_mapping.py`, `api/tests/test_mapping_golden.py` (строки с `counterparty_id` имеют kind, без — NULL) +- Create: `api/alembic/versions/0004_dbml_v5_16.py` + +**Interfaces:** +- `@dataclass(frozen=True) class Resolution: entity_id: uuid.UUID; kind: CounterpartyMatchKind` в `repositories/counterparties.py`; `CounterpartyIndex.resolve(inn, name) -> Resolution | None` (ИНН → `INN`; точное имя → `NAME`; нормализованное имя ЮЛ или алиас → `ALIAS`). +- Миграция (стиль 0003): `upgrade`: `CREATE TYPE dkb.counterparty_match_kind AS ENUM ('INN','NAME','ALIAS','MANUAL','PROPAGATED')`; `ALTER TABLE dkb.osv_entry ADD COLUMN counterparty_match_kind dkb.counterparty_match_kind`; backfill тремя `UPDATE … FROM dkb.legal_entity l WHERE e.counterparty_id = l.id AND e.counterparty_match_kind IS NULL`: `regexp_replace(coalesce(e.counterparty_inn,''), '\s', '', 'g') = l.inn` → `INN`; `btrim(e.counterparty_name) = l.name` → `NAME`; `e.counterparty_id IS NOT NULL` → `ALIAS`. `downgrade`: `DROP COLUMN`, `DROP TYPE`. + +- [ ] **Step 1: Тесты.** `test_enums` (`"5.16"`, 28 enum, значения kind); `test_migrations`: `HEAD`, тест данных — на ревизии 0003 вставить ЮЛ (ИНН `7701000001`, имя `ООО Ромашка`, алиас `ромашка плюс`) и четыре `osv_entry` (ИНН совпал / имя совпало / только алиас / без контрагента) → после `upgrade head` kind = `INN`/`NAME`/`ALIAS`/NULL; `downgrade 0003` → колонки нет, `enum_range(NULL::dkb.counterparty_match_kind)` падает; `test_counterparty_resolve`: `Resolution.kind` для трёх шагов; `test_result_mapping`/`test_mapping_golden`: `counterparty_match_kind` заполнен ⇔ `counterparty_id` заполнен; `test_api_documents.test_entries_*`: `match_kind` в `EntryOut`. +- [ ] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_migrations.py tests/test_result_mapping.py tests/test_api_documents.py -q`; `pytest -m "integration and fixtures" tests/test_mapping_golden.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(model): DBML v5.16 — counterparty_match_kind, миграция 0004, шаг резолва в маппинге` (+ коммит в docs `docs: DBML v5.16, entities-v5.16, guide`). + +--- + +### Task 2: [1.2] Фабрики, репозиторий консолидаций, схемы + +**Files:** +- Create: `api/src/repositories/consolidations.py`, `api/src/schemas/consolidations.py`, `api/tests/test_repositories_consolidations.py` +- Modify: `api/tests/factories.py`, `api/tests/conftest.py` (`CommittedJobs.cleanup()` удаляет `consolidation_document`, `consolidation_lock`, `consolidation_status_history`, `consolidation_member`, `consolidation_version`, `consolidation` до файлов) + +**Interfaces:** +- Фабрики: `make_consolidation(session, *, group: CompanyGroup, year: int, quarter: PeriodQuarter, members: Sequence[LegalEntity], initiator: AppUser, status=ConsolidationStatus.INITIATED) -> tuple[Consolidation, ConsolidationVersion]` (версия №1, история первой записью, `consolidation_member` на каждое ЮЛ); `make_lock(session, *, version, user: AppUser, released: bool = False) -> ConsolidationLock`; `make_confirmed_osv(session, *, entity: LegalEntity, account: str, year: int, quarter: PeriodQuarter, is_cumulative: bool, rows: Sequence[dict[str, Any]], confirmed_by: uuid.UUID | None = None) -> Document` (файл `PROCESSED` + задание `done` + документ `CONFIRMED` + строки `COUNTERPARTY`/`BU` из `rows`: `name`, `inn`, `opening_debit`, …; `row_number` по порядку). +- Репозиторий (все функции принимают `session`, без policy, кроме `get_version`): `get_version(session, scope, version_id) -> tuple[ConsolidationVersion, Consolidation] | None` (join `consolidation` → `company_group`, `company_groups_visible`); `get_consolidation_for_group(session, scope, group_id, year, quarter)`; `list_periods(session, scope, group_id) -> list[tuple[Consolidation, list[ConsolidationVersion]]]`; `lock_version(session, version_id) -> ConsolidationVersion | None` (`FOR UPDATE`, `populate_existing`); `lock_versions_for_entity(session, entity_id, statuses) -> list[ConsolidationVersion]` (версии «в работе» с ЮЛ в периметре, `ORDER BY id FOR UPDATE`); `active_lock(session, version_id) -> ConsolidationLock | None`; `members(session, version_id) -> list[tuple[ConsolidationMember, LegalEntity]]` (по имени ЮЛ); `bound_documents(session, version_id) -> list[Document]`; `document_versions(session, document_id) -> list[uuid.UUID]`; `load_osv_rows(session, version_id) -> list[tuple[OsvEntry, Document, LegalEntity]]` (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`, через `consolidation_document`, `ORDER BY LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number`); `history(session, version_id) -> list[ConsolidationStatusHistory]`; `entity_files_state(session, entity_ids) -> dict[uuid.UUID, EntityFilesState]` (`has_in_progress`, `last_file_status`, неподтверждённые документы по (тип, счёт, год, квартал, is_cumulative, status)). +- Схемы (D-16): `ConsolidationCreate`, `LockOut`, `VersionBrief`, `ConsolidationPeriodOut`, `MemberOut` (+ `checklist: list[ChecklistCellOut]`), `HistoryOut`, `VersionCard`, `MembersIn`, `LockIn {takeover_reason: str | None}`, `StatusIn {to_status, comment}`, `ChecklistOut {rules[], rows[]}`, `ChecklistCellOut {status, missing_quarters, document_ids}`, `ConsolidatedRowOut`, `OsvSummaryOut`, `GapOut`, `OsvFilters` (query-модель), `CandidateOut`, `MapCounterpartyIn {legal_entity_id, move_alias: bool = False}`, `MapCounterpartyOut {row, propagated, alias_created, alias_moved_from}`. Суммы — `Decimal | None`. + +- [ ] **Step 1: Тесты.** `test_repositories_consolidations.py` (integration): `list_periods` сортирует 2024 Q3 перед 2024 Q1 и версии по убыванию; `get_version` для ГК чужого филиала → None; `lock_versions_for_entity` отдаёт только «в работе» версии с ЮЛ в периметре по `id`; `load_osv_rows` отбрасывает `TOTAL`/`ACCOUNT`/`NU`; `test_factories`: новые фабрики создают связные строки. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_repositories_consolidations.py tests/test_factories.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): репозиторий, схемы и фабрики консолидаций`. + +--- + +### Task 3: [2.1] Создание, список, карточка + +**Files:** +- Create: `api/src/domain/consolidation_periods.py` (`reporting_date(year, quarter) -> date`), `api/src/services/consolidations.py`, `api/src/api/dependencies/consolidations.py`, `api/src/api/endpoints/consolidations/__init__.py` (`router` с подроутерами), `api/src/api/endpoints/consolidations/versions.py`, `api/tests/test_api_consolidations.py`, `api/tests/test_domain_consolidation_periods.py` +- Modify: `api/src/api/router.py` (`include_router(consolidations.router)`), `api/tests/test_openapi_routes.py` (новые маршруты) + +**Interfaces:** +- `ConsolidationService(session, audit: RequestAudit, scope, principal: UserWithAccess)`: `create(data: ConsolidationCreate) -> VersionCard`; `list_periods(group_id, *, in_work_only) -> list[ConsolidationPeriodOut]`; `card(version_id) -> VersionCard`; внутренняя `_card(version, consolidation)` собирает `members` с ячейками через `domain.checklist.build` (Task 8; до него — `checklist = []`), `history`, `lock`. +- Ошибки: `CONSOLIDATION_NOT_FOUND`, `VERSION_NOT_FOUND` (`NotFoundError`), `RELATED_GROUP_MISMATCH`, `PERIMETER_TOO_SMALL` (`DomainValidationError`), `CONSOLIDATION_EXISTS` (`ConflictError`; `IntegrityError` по `uq_consolidation_group_year_quarter` → 409, как `constraint_races`). +- `create`: ГК видима (`company_group_is_visible`) → ГСК `get_related_group` + `kind == gsk` + `company_group_id == group` → `current_members` → ≥ 2 → `Consolidation(reporting_date)` + `ConsolidationVersion(version_no=1, INITIATED, CREATE, BASIC, initiator_id, initiator_office_id = principal.user.regional_office_id, created_by, created_at)` + `ConsolidationMember` × N + `ConsolidationStatusHistory(None → INITIATED, changed_by)` + подхват `consolidation_documents.bind_confirmed(version, entity_ids)` (Task 6; до него — заглушка `return 0`) + `audit(CONSOLIDATION_CREATE, object = consolidation_version, after = {consolidation_id, year, quarter, members: [...]})`. +- Маршруты: `POST /consolidations` (201), `GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`; права `require("CONSOLIDATION_EDIT")` / `require("CONSOLIDATION_VIEW")`. + +- [ ] **Step 1: Тесты.** Домен: `reporting_date(2024, Q1) == date(2024, 4, 1)`, `Q4 → date(2025, 1, 1)`. API (integration, `auth_client`): сценарии спеки «Создание» (РБ Q3 2024, один член, повтор периода, `year = 1999` → 422), «Список и карточка» (дерево, чужой филиал 404 с телом как у случайного uuid, `in_work_only`), `curator_go` читает карточку, `expert` без `CONSOLIDATION_EDIT` → 403 на создании; `CONSOLIDATION_CREATE` с `consolidation_version_id`. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`. + +--- + +### Task 4: [2.2 + 2.3] Политика редактируемости, статусы, блокировка + +**Files:** +- Create: `api/src/services/consolidation_status.py`, `api/src/services/consolidation_locks.py`, `api/src/api/endpoints/consolidations/locks.py`, `api/src/api/endpoints/consolidations/status.py`, `api/tests/test_api_consolidation_locks.py`, `api/tests/test_api_consolidation_status.py` +- Modify: `api/src/domain/policy.py`, `api/tests/test_domain_policy.py`, `api/tests/test_api_constraint_races.py` + +**Interfaces:** +- `domain/policy.py`: `IN_WORK_STATUSES: frozenset[ConsolidationStatus]`; `ensure_version_editable(version: ConsolidationVersion, active_lock: ConsolidationLock | None, user_id: uuid.UUID) -> None` → `ConflictError("VERSION_NOT_EDITABLE")` / `("LOCK_REQUIRED")` / `("LOCKED_BY_OTHER", locked_by=…, locked_at=…)`. +- `consolidation_status.py`: `MANUAL_TRANSITIONS: dict[ConsolidationStatus, frozenset[ConsolidationStatus]]` (`CANCELLED` из всех «в работе»; `INITIATED` из `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`); `async def transition(session, audit: AuditService, *, version, to, changed_by, comment, ip_address=None, user_agent=None, now) -> ConsolidationStatusHistory` — меняет `version.status`, `updated_by/at`, при `CANCELLED` — `cancelled_reason`, `is_in_work = false`, `release_active_lock`; пишет историю и `CONSOLIDATION_STATUS_CHANGE` (`user_id = changed_by`). Версия должна быть уже под `FOR UPDATE` вызывающим. Автоматические переходы (Task 6–7) зовут ту же функцию. +- `ConsolidationStatusService.change(version_id, data: StatusIn) -> VersionCard`: `lock_version` → видимость → `ensure_version_editable` → `to in MANUAL_TRANSITIONS[status]` иначе 409 `TRANSITION_NOT_ALLOWED` (`extra: from_status, to_status`) → `CANCELLED` без `comment` → 422 `COMMENT_REQUIRED`. +- `ConsolidationLockService`: `take(version_id, data: LockIn) -> tuple[LockOut, bool created]` (`lock_version` → видимость → `status is CANCELLED` → 409 `VERSION_NOT_EDITABLE` → `active_lock`: нет → insert (`IntegrityError` → 409 `LOCKED_BY_OTHER`), своя → вернуть, чужая без причины → 409 `LOCKED_BY_OTHER`, с причиной → release + insert(`taken_over_from`, `takeover_reason`) + `CONSOLIDATION_LOCK_TAKEOVER`); `release(version_id) -> None` (своя → `released_at`, аудит `CONSOLIDATION_LOCK after.released = true`; чужая → 409 `NOT_LOCK_OWNER`; нет → noop). `release_active_lock(session, version_id, now)` — общая для `CANCELLED`. +- Маршруты: `POST …/lock` (201 создана / 200 своя), `DELETE …/lock` (204), `POST …/status` (200). + +- [ ] **Step 1: Тесты.** Домен: таблица `ensure_version_editable` (4 случая). API: сценарии спеки «Взять в работу, перехват, снятие», «Статусы Среза 4» (аннулирование снимает блокировку, `TRANSITION_NOT_ALLOWED` для `CONSOLIDATION_RF` и для повтора статуса, `COMMENT_REQUIRED`, возврат в `INITIATED`), `PUT …/members`-заглушка не нужна — `LOCK_REQUIRED` проверяется на `POST …/status`; гонка двух `POST …/lock` на `committed_client` (один 201, другой 409); Review Focus 4 — перехват во время правки (на `committed_sessionmaker`: сессия А держит `lock_version`, сессия Б вызывает перехват и ждёт; после commit А перехват проходит). +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_locks.py tests/test_api_consolidation_status.py tests/test_api_constraint_races.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): политика редактируемости, блокировка с перехватом, ручные статусы Среза 4`. + +--- + +### Task 5: [2.4] Периметр версии + +**Files:** +- Create: `api/src/api/endpoints/consolidations/members.py`, `api/tests/test_api_consolidation_members.py` +- Modify: `api/src/services/consolidations.py` (`set_members`) + +**Interfaces:** +- `ConsolidationService.set_members(version_id, data: MembersIn) -> VersionCard`: `lock_version` → видимость → `ensure_version_editable` → уникальные id → все ЮЛ с `company_group_id == consolidation.company_group_id` (иначе 422 `ENTITY_NOT_IN_GROUP`, `extra.legal_entity_ids`) → ≥ 2 → diff: удалить `ConsolidationMember` ушедших + `consolidation_documents.unbind_entity(version, entity_id)` (Task 6; до него — удаление `consolidation_document` по `document.legal_entity_id` прямо здесь), добавить новых + `bind_confirmed` → `audit(MEMBERSHIP_CHANGE, before = {legal_entity_ids: старые}, after = {legal_entity_ids: новые})`. +- Маршрут: `PUT …/members` (200, карточка). + +- [ ] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился). +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`. + +--- + +### Task 6: [3.1] Привязка документов: подтверждение, подхват, автозамена + +**Files:** +- Create: `api/src/services/consolidation_documents.py`, `api/tests/test_consolidation_documents.py` +- Modify: `api/src/services/documents.py` (`confirm`), `api/src/services/result_mapping.py` (`supersede_previous`), `api/src/services/consolidations.py` (заглушки → реальные вызовы), `api/tests/test_api_documents.py`, `api/tests/test_result_mapping.py` + +**Interfaces:** +- `def fits(document: Document, version: ConsolidationVersion, consolidation: Consolidation, member_ids: Collection[uuid.UUID]) -> bool` (чистая, D-5; `is_cumulative` NULL → нарастающий). +- `async def bind_confirmed(session, *, version, consolidation, entity_ids) -> int` — подхват: `CONFIRMED` документы ЮЛ из файлов `status != SUPERSEDED`, `fits`, на тройку `(document_type, osv_account_code, period_quarter)` — max `confirmed_at`; существующая привязка той же тройки заменяется; возвращает число привязанных. +- `async def unbind_entity(session, *, version_id, entity_id) -> int`. +- `async def on_document_confirmed(session, audit: AuditService, *, document, user_id, now) -> list[uuid.UUID]` — `lock_versions_for_entity(document.legal_entity_id, IN_WORK_STATUSES)` → для подходящих: заменить тройку, insert `ConsolidationDocument`, если статус `INITIATED`/`DOCUMENTS_PROCESSING` → `transition(to=DATA_CORRECTION, changed_by=user_id)`; возвращает id версий. +- `async def on_documents_superseded(session, *, documents: Sequence[Document], now) -> list[uuid.UUID]` — версии «в работе», где эти документы привязаны (`FOR UPDATE` по `id`), удалить привязки; возвращает `unbound_version_ids`. +- `DocumentService.confirm`: после установки `CONFIRMED` и перед аудитом — `on_document_confirmed(...)`; `supersede_previous`: после `UPDATE stored_file` — `on_documents_superseded(replaced_documents всех файлов)`, `unbound_version_ids` в `after_value` `FILE_REPLACE`. + +- [ ] **Step 1: Тесты.** Unit `fits`: таблица из 8 случаев (член/не член, год, нарастающий Q2/Q3 при версии Q3, квартальный Q1/Q4, `period_quarter` NULL, `is_cumulative` NULL). Integration: сценарии `documents-api` «Привязка к версии и переход статуса», «Не подходит по периоду», «Версия в CANCELLED», «Фикстуры, подтверждённые до консолидации» (через `make_confirmed_osv` × 3 + создание через API), «Две подтверждённые версии одного документа»; `primary-files` «Заменённый документ был в версии» (через `supersede_previous` напрямую, как в `test_result_mapping`); Review Focus 1 — две версии Q2 и Q3 получают один квартальный документ Q1 и обе переходят в `DATA_CORRECTION`; порядок блокировок: подтверждение ∥ `PUT …/members` на двух сессиях не взаимоблокируется (таймаут 5 с). +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_consolidation_documents.py tests/test_api_documents.py tests/test_result_mapping.py tests/test_api_consolidations.py tests/test_api_consolidation_members.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене`. + +--- + +### Task 7: [3.2] Статусы по событиям файлов: загрузка с версией, NEEDS_CHECK при маппинге + +**Files:** +- Modify: `api/src/api/endpoints/files/upload.py` (query `consolidation_version_id: uuid.UUID | None = None`), `api/src/services/primary_files.py` (`check_version_for_upload(version_id, entity) -> ConsolidationVersion`, `after_upload_to_version(version_id)`), `api/src/services/result_mapping.py` (`_map_candidate`: после `map_file` — `consolidation_documents.on_documents_mapped(session, file=file, documents=…, now=now)`), `api/src/services/consolidation_documents.py` (`on_documents_mapped`), `api/tests/test_api_files_upload.py`, `api/tests/test_result_mapping.py` + +**Interfaces:** +- Загрузка: до приёма — `get_version(scope)` (404 `VERSION_NOT_FOUND`) → `status ∈ IN_WORK_STATUSES` (409 `VERSION_NOT_EDITABLE`) → ЮЛ ∈ `members` (422 `ENTITY_NOT_IN_PERIMETER`); после цикла по файлам, если `accepted` и статус `INITIATED` — отдельная транзакция (`session_transaction` как у загрузки файла): `lock_version` → если всё ещё `INITIATED` → `transition(to=DOCUMENTS_PROCESSING, changed_by=user)`. +- `on_documents_mapped(session, *, file, documents, now) -> list[uuid.UUID]`: если `file.uploaded_by is None` → `logger.info` и `[]`; версии `DOCUMENTS_PROCESSING` с ЮЛ файла в периметре (`FOR UPDATE` по `id`); для каждой — есть документ `NEEDS_CHECK` с `fits` → `transition(to=DATA_CORRECTION, changed_by=file.uploaded_by)`; привязки не создаёт. + +- [ ] **Step 1: Тесты.** Сценарии `primary-files` «Загрузка в контексте версии» (переход, запись истории), «ЮЛ вне периметра» (422, файлов нет), версия `CANCELLED` → 409, чужая версия → 404; `osv-result-mapping` «Предупреждение маппинга двигает статус», «Документ RECOGNIZED» (без перехода), `uploaded_by` NULL — без перехода и без ошибки. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_files_upload.py tests/test_result_mapping.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): переходы DOCUMENTS_PROCESSING и DATA_CORRECTION по загрузке и маппингу`. + +--- + +### Task 8: [3.3] Чек-лист + +**Files:** +- Create: `api/src/domain/checklist.py`, `api/src/api/endpoints/consolidations/checklist.py`, `api/tests/test_domain_checklist.py`, `api/tests/test_api_consolidation_checklist.py` +- Modify: `api/src/services/consolidations.py` (`checklist(version_id) -> ChecklistOut`; `_card` заполняет `members[].checklist`), `api/src/repositories/consolidations.py` (`rules_for(approach) -> list[tuple[RequiredDocumentRule, str | None account_code]]`, `entity_files_state`) + +**Interfaces:** +- `domain/checklist.py`: `@dataclass(frozen=True) class Rule: document_type: DocumentType; account: str | None; is_mandatory: bool`; `class CellStatus(StrEnum): CONFIRMED, NEEDS_CHECK, RECOGNIZED, PROCESSING, ERROR, MISSING`; `@dataclass(frozen=True) class Cell: status; missing_quarters: tuple[PeriodQuarter, ...]; document_ids: tuple[uuid.UUID, ...]`; `def build(rules, *, version_quarter, members: Sequence[LegalEntity], bound: Sequence[Document], unbound: Sequence[Document], files_state: Mapping[uuid.UUID, EntityFilesState]) -> list[tuple[LegalEntity, list[Cell]]]` — для ОСВ использует `consolidation_periods.select_sources` (Task 9; до него — локальная проверка «нарастающий за квартал версии или все кварталы Q1..Qn»; в Task 9 заменить на `select_sources`), для ББ/ОФР — документ за квартал версии. +- Порядок правил: `BALANCE`, `INCOME`, затем ОСВ по `account` (натуральная сортировка кодов `01 < 58 < 60`). + +- [ ] **Step 1: Тесты.** Домен: пробел Q2 (`MISSING`, `["Q2"]`, два `document_ids`), субсчёт 58.03 не закрывает 58, `NEEDS_CHECK` при неподтверждённом подходящем, `PROCESSING` при файле в работе, `ERROR`, ББ за квартал версии `CONFIRMED`, ББ за Q2 при версии Q3 — `MISSING`. API: `GET …/checklist` на версии РБ с тремя ОСВ 60 → ячейка ОСВ 60 `CONFIRMED`, ОСВ 90 `MISSING`; карточка содержит те же ячейки; `curator_go` читает. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): чек-лист комплекта документов по правилам подхода`. + +--- + +### Task 9: [4.1] Домен сводной ОСВ: выбор источников, склейка, признак, фильтры + +**Files:** +- Create: `api/src/domain/consolidated_osv.py`, `api/tests/test_domain_consolidated_osv.py`, `api/tests/test_domain_consolidation_periods.py` (дополнить) +- Modify: `api/src/domain/consolidation_periods.py` (`select_sources`), `api/src/domain/checklist.py` (использовать `select_sources`) + +**Interfaces:** +- `consolidation_periods.py`: `@dataclass(frozen=True) class Sources: documents: tuple[Document-like, ...]; cumulative: bool`; `@dataclass(frozen=True) class Gap: missing_quarters: tuple[PeriodQuarter, ...]`; `def select_sources(version_quarter: PeriodQuarter, documents: Sequence[DocLike]) -> Sources | Gap` — `DocLike` — `Protocol` с `id`, `period_quarter`, `is_cumulative` (домен не импортирует ORM-классы в рантайме, только `TYPE_CHECKING`). `QUARTER_ORDER = (Q1, Q2, Q3, Q4)`. +- `consolidated_osv.py`: входные dataclass'ы `SourceRow` (из `OsvEntry`: `id`, `document_id`, `row_number`, `account_code`, `account_root`, `counterparty_name`, `counterparty_inn`, `counterparty_id`, `match_kind`, шесть сумм), `SourceDocument` (`id`, `legal_entity_id`, `osv_account_code`, `period_quarter`, `is_cumulative`), `Entity` (`id`, `name`, `inn`, `company_group_id`); `class CounterpartyType(StrEnum): GSK, GK, EXTERNAL`; `@dataclass(frozen=True) class ConsolidatedRow` (поля D-16 + `warnings: tuple[str, ...]`); `@dataclass(frozen=True) class GapItem(legal_entity_id, legal_entity_name, account_code, missing_quarters)`; `@dataclass(frozen=True) class Consolidated: rows: tuple[ConsolidatedRow, ...]; gaps: tuple[GapItem, ...]; documents_total: int`; `class Resolver(Protocol): def resolve(self, inn, name) -> Resolution | None`; `def build(*, year: int, quarter: PeriodQuarter, group_id: uuid.UUID, members: Mapping[uuid.UUID, Entity], entities: Mapping[uuid.UUID, Entity], documents: Sequence[SourceDocument], rows: Sequence[SourceRow], resolver: Resolver) -> Consolidated`; `@dataclass(frozen=True) class OsvFilter(legal_entity_id, account_root, counterparty_type, unmapped, match_kinds: frozenset, q)`; `def apply_filter(rows, f) -> list[ConsolidatedRow]`; `def summary(c: Consolidated) -> tuple[int rows_total, int unmapped_total, int auto_matched_total]`. +- Ключ склейки: `(account_code or "", (counterparty_name or "").strip(), normalize_inn(counterparty_inn or ""))`; `counterparty_id` из последнего квартала (D-3, #8), `warnings` при расхождении; `row_number` = min по последнему кварталу; нули — `Decimal("0")`; суммы `None` считаются нулями при сложении, но строка нарастающего документа отдаётся как есть. + +- [ ] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. +- [ ] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. + +--- + +### Task 10: [4.2] API свода: страница и сводка + +**Files:** +- Create: `api/src/services/consolidated_osv.py`, `api/src/api/endpoints/consolidations/osv.py`, `api/tests/test_api_consolidated_osv.py` + +**Interfaces:** +- `ConsolidatedOsvService(session, scope)`: `async def _build(version_id) -> tuple[Consolidated, ConsolidationVersion, Consolidation]` (`get_version` → `members` → `bound_documents` (ОСВ) → `load_osv_rows` → `CounterpartyIndex.load` → `entities` для `counterparty_id` и кандидатов дорезолва (один `SELECT legal_entity` по собранным id) → `build`); `page(version_id, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]`; `summary(version_id) -> OsvSummaryOut`. +- `OsvPageParams`: `limit` ≤ 2000, по умолчанию 500 (как `EntriesPageParams`). Маршруты: `GET …/osv`, `GET …/osv/summary` (`CONSOLIDATION_VIEW`). + +- [ ] **Step 1: Тесты.** Integration на `make_confirmed_osv` ×3 квартала ×2 ЮЛ: страница, `total`, сценарии «Только несопоставленные», «Фильтр по способу», `q` по имени/ИНН, `legal_entity_id`, `account_root`, чтение версии `CANCELLED` → 200, `curator_go` → 200, чужой филиал → 404, `limit=2001` → 422; `summary.gaps` при пробеле; суммы в JSON — строки `"100.00"`. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidated_osv.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами`. + +--- + +### Task 11: [4.3] Кандидаты и ручное сопоставление + +**Files:** +- Create: `api/src/services/counterparty_mapping.py`, `api/src/api/endpoints/consolidations/mapping.py`, `api/tests/test_api_counterparty_mapping.py` +- Modify: `api/src/services/consolidated_osv.py` (`candidates`), `api/src/repositories/consolidations.py` (`entry_in_version(entry_id, version_id) -> OsvEntry | None`, `entries_same_name(version_id, name_stripped) -> list[OsvEntry]`, `search_entities(q, *, ids: Collection | None) -> list[LegalEntity]` + текущие ГСК/ГСЗ), `api/src/repositories/directories.py` (`find_alias_owners(session, normalized) -> list[LegalEntityAlias]` — если `find_alias_owner` одиночный, добавить множественную) + +**Interfaces:** +- `candidates(version_id, *, q, scope: Literal["group","all"], entry_id) -> list[CandidateOut]`: `group` — ЮЛ с `company_group_id == consolidation.company_group_id` (включая периметр); `all` — `len(q) >= 3` иначе 422 `QUERY_TOO_SHORT`; поиск ИНН по префиксу / `ILIKE %q%` по имени / алиасу; `recommended` по `normalize_alias(entry.counterparty_name)`; порядок: рекомендуемые, имя; ≤ 100. +- `CounterpartyMappingService(session, audit: RequestAudit, scope).map(version_id, entry_id, data: MapCounterpartyIn) -> MapCounterpartyOut`: `lock_version` → видимость → `ensure_version_editable` → `entry_in_version` (404 `ENTRY_NOT_FOUND`) → ЮЛ (`session.get(LegalEntity)`, 404 `LEGAL_ENTITY_NOT_FOUND`) → `normalized` → владельцы ≠ target: `move_alias` false → 409 `ALIAS_TAKEN` (`owners: [{legal_entity_id, name}]`); true → delete каждого + `LEGAL_ENTITY_CHANGE` (`object_type = legal_entity_alias`, `before = {alias, legal_entity_id}`) → алиаса у target нет и `normalized` непуст → `LegalEntityAlias(alias = counterparty_name.strip(), normalized_alias, created_by)` + `LEGAL_ENTITY_CHANGE` → строке `counterparty_id`, `MANUAL` → `entries_same_name` с `counterparty_id IS NULL` и (`counterparty_inn` пуст или `normalize_inn == target.inn`) → `PROPAGATED` → `OSV_COUNTERPARTY_MAP` (`before = {counterparty_id, match_kind}`, `after = {counterparty_id, match_kind: MANUAL, propagated_entry_ids, alias_created, alias_moved_from}`, `consolidation_version_id`) → ответ с пересчитанной строкой (`_build` заново, строка по `entry_id ∈ entry_ids`). +- Маршруты: `GET …/counterparty-candidates` (`CONSOLIDATION_VIEW`), `POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, 200). + +- [ ] **Step 1: Тесты.** Сценарии спеки «Кандидаты» (рекомендуемая Счастливочная, `scope=all` с коротким `q` → 422, ЮЛ другого филиала находится в `all`), «Ручное сопоставление» (сопоставление из плана среза с `propagated` по трём ОСВ, `ALIAS_TAKEN`, перенос алиаса с двумя `LEGAL_ENTITY_CHANGE`, ИНН противоречит, перевязка, `ENTRY_NOT_FOUND` для строки непривязанного документа, без блокировки → `LOCK_REQUIRED`, `CANCELLED` → `VERSION_NOT_EDITABLE`); Review Focus 1 — строка видна во второй версии с `MANUAL`; Review Focus 3 — имя `ООО` → алиас не создан, строка связана. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_counterparty_mapping.py tests/test_api_aliases.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): кандидаты и ручное сопоставление контрагента с алиасом и автоприменением`. + +--- + +### Task 12: [4.4] Экспорт xlsx + +**Files:** +- Create: `api/src/services/osv_export.py`, `api/src/api/endpoints/consolidations/export.py`, `api/tests/test_api_osv_export.py` +- Modify: `api/requirements-dev.txt` (ничего, openpyxl есть), `api/pyproject.toml` (mypy override `openpyxl.*` `ignore_missing_imports`, если стабы не установлены) + +**Interfaces:** +- `def build_workbook(c: Consolidated, rows: Sequence[ConsolidatedRow], *, entity_names: Mapping[uuid.UUID, str]) -> bytes` — `openpyxl.Workbook()`, лист «Сводная ОСВ» (15 колонок D-14, `number_format = "#,##0.00"`, суммы `float(Decimal)` недопустимы → писать `Decimal` напрямую: openpyxl принимает `Decimal` как число), лист «Пробелы»; `BytesIO`. +- `ConsolidatedOsvService.export(version_id, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str filename]`; аудит `EXPORT` (`section = EXPORT`, `after = {report: "consolidated_osv", filters: f.model_dump(exclude_none=True), rows: len(rows)}`). +- Маршрут `GET …/osv/export` → `Response(content, media_type=…, headers={"Content-Disposition": f'attachment; filename="svod-osv.xlsx"; filename*=UTF-8\'\'{quote(name)}'})`. + +- [ ] **Step 1: Тесты.** Сценарий «Экспорт читается обратно» (`openpyxl.load_workbook(BytesIO(resp.content))`: заголовки, 3 строки, `Decimal(str(cell.value))` равен API, лист «Пробелы» с `Q2`, аудит `rows = 3`); Review Focus 5 — пустой лист при `unmapped=true`; `Content-Disposition` содержит `filename*=UTF-8''`; `expert` (VIEW) → 200, без права → 403. +- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_osv_export.py -q` → PASS. +- [ ] **Step 5: Commit** `feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов`. + +--- + +### Task 13: [5.1] Сквозной тест на фикстурах РБ + +**Files:** +- Create: `api/tests/test_consolidation_fixtures.py` (маркеры `integration`, `fixtures`) +- Modify: `api/tests/fixtures_helpers.py` (хелпер `map_golden_snapshot(session, *, entity, path, uploaded_by) -> list[Document]` — на базе `_payload`/`map_file` из `test_mapping_golden`, если его там нет — вынести) + +**Interfaces:** +- Сценарий: seed ГК «Русский бисквит» (СПб РФ) с ЮЛ АО РБ (ИНН 3528052718) и ООО «Пекарня Счастливочная» (ИНН из `fixtures/directories/ecp-export-russkiy-biskvit.xlsx`, см. `test_import_full_export`) и ГСК из них; маппинг golden `rb/2024-Q1..Q3/*.json` и `schastl/2024-Q1..Q3/*.json` через `map_file` (реальный `CounterpartyIndex`); подтверждение всех документов через `POST /documents/{id}/confirm` (`ka_rf`); `POST /consolidations` 2024 Q3 → `INITIATED` (подхват) → `POST …/lock` → `GET …/osv/summary`: строки есть по счетам 60 и 62 у обеих и 76 у Счастливочной; `gaps` содержат РБ 66/67/76 с `missing_quarters ⊇ [Q1, Q2]` (и Счастливочной 01/02); ячейка чек-листа ОСВ 58 РБ — не `CONFIRMED` (есть только 58.3); `GET …/osv?unmapped=true` содержит строки РБ «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (имя как в файле, без учёта регистра — найти по `normalize_alias`); `POST …/osv/entries/{id}/counterparty` → `GSK`, `propagated ≥ 1`; повторный `summary.unmapped_total` уменьшился на `1 + propagated`; все строки с этим именем во всех счетах РБ — `GSK`; строка Счастливочной «Русский бисквит» с ИНН — `GSK`, `match_kind = INN`; `GET …/osv/export` открывается `openpyxl`, число строк = `rows_total`. +- Время теста ≤ 60 с; выводить в лог число строк свода. + +- [ ] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). +- [ ] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). +- [ ] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»`. + +--- + +### Task 14: [5.2 + docs 2–4] Документы + +**Files:** +- Modify: `README.md` (раздел «Срез 4»: эндпоинты, статусы, блокировка, привязка, свод, сопоставление, экспорт, коды ошибок), `CLAUDE.md` (новые модули, порядок блокировок «файл → документ → версия», правило «переходы только через `transition`»), `openspec/changes/slice-4-consolidation/tasks.md` (все `[x]`) +- Modify (worktree docs): `context/05-domain-rules.md` (§2, §3, §9.1, §9.3, §9.5, §10 по proposal docs-change), `context/03-architecture.md`, `context/07-open-questions.md` (закрыть OQ-03, OQ-19, OQ-40, OQ-110 (Срез 4); добавить OQ-118…OQ-123 с текстами из design D-6/D-7/D-12/D-1/D-13/Risks), `context/09-delivery-plan.md` (Срез 4 по факту), `context/10-user-journey.md` (эндпоинты у шагов 2.1–2.6, 3.1), `dev/qa-guide.md` §7, новый `dev/api-contract-slice-4.md` (маршрут → схема → коды ошибок → экран макета; выжимка из OpenAPI `GET /openapi.json`), `openspec/changes/slice-4-consolidation/tasks.md` 2–4 → `[x]` + +- [ ] **Step 1:** Проверить `GET /openapi.json` на тестовом приложении (`test_openapi_routes`) — все маршруты консолидации с `operation_id` и `responses`. +- [ ] **Step 2:** Написать документы; `make check` (линтер markdown, если есть); коммит в arm-core `docs(slice-4): README и CLAUDE.md по консолидации`; коммит в docs `docs(slice-4): правила §2/§3/§9/§10 по факту, OQ, контракт API для фронта, QA-гайд`. + +--- + +## Self-review + +- **Spec coverage:** consolidations-api — Task 3 (создание, список, карточка), 5 (периметр), 4 (блокировка, статусы), 8 (чек-лист); consolidated-osv — 9/10 (источники, строки, страница, сводка), 11 (кандидаты, сопоставление), 12 (экспорт); data-model — 1; documents-api — 6; primary-files — 6 (автозамена), 7 (загрузка); osv-result-mapping — 1 (kind), 7 (NEEDS_CHECK); directory-policy — 4; audit-trail — 3/4/5/11/12 (события), 6 (`unbound_version_ids`). +- **Placeholders:** нет «TBD»; заглушки Task 3/5 (`bind_confirmed`, `unbind_entity`) явно заменяются в Task 6. +- **Type consistency:** `transition(...)` — Task 4, используется в 6, 7; `fits` — Task 6, используется в 7, 8; `select_sources` — Task 9, используется в 8 (замена локальной проверки); `Resolution` — Task 1, используется в 9/10; `ensure_version_editable` — Task 4, в 5/11; `_build` — Task 10, в 11/12. +- **Review Focus:** 1 → Task 6, 11; 2 → Task 9; 3 → Task 11; 4 → Task 4; 5 → Task 12. -- 2.47.2 From be509f2bf5447f15c3ca8929085b3e0a52860dde Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:22:26 +0400 Subject: [PATCH 03/27] =?UTF-8?q?docs(slice-4):=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1=D1=81?= =?UTF-8?q?=D1=83=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=84=9611?= =?UTF-8?q?=E2=80=9315=20(pre-flight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рулинги контроллера R-1…R-5 из pre-flight-проверки плана: ключ привязки с признаком нарастающего, блокировка надмножества версий, FOR NO KEY UPDATE на документах/файлах, Resolution в домене, разнесение EntityFilesState между задачами. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/changes/slice-4-consolidation/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index fd1926d..4e4a3cc 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -195,3 +195,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 8 | 2026-10-06 | `counterparty_id` строки свода при расхождении между кварталами — из последнего квартала, расхождение в `warnings` | нужно одно значение на строку; автоприменение выравнивает | аналитик видит предупреждение, не ошибку | | 9 | 2026-10-06 | `GET …/osv` и экспорт не требуют блокировки, только `CONSOLIDATION_VIEW` | чтение | нет | | 10 | 2026-10-06 | ББ/ОФР (`is_cumulative` NULL) считаются нарастающими при привязке | формы всегда за период с начала года | нет | +| 11 | 2026-10-06 | R-1: ключ замены/выбора «самого позднего `confirmed_at`» при привязке = `(document_type, osv_account_code, period_quarter, cumulative)`, `cumulative = (is_cumulative is not False)` | тройка без признака нарастающего вытесняла бы нарастающий Q3 квартальным Q3 одной пары (ЮЛ, счёт), а сценарий «Нарастающий рядом с квартальными» требует обоих | если владелец хотел «один документ на квартал» — нарастающий и квартальный вытесняли бы друг друга; меняется только ключ в `bind_confirmed`/`on_document_confirmed` | +| 12 | 2026-10-06 | R-2: `lock_versions_for_entity` блокирует `FOR UPDATE ORDER BY id` надмножество версий «в работе» (ЮЛ — член ИЛИ `company_group_id` совпадает), затем отдельным запросом возвращает только версии, где ЮЛ — член; создание версии берёт `pg_advisory_xact_lock` по ГК | иначе confirm ∥ `PUT …/members` теряет привязку (не видит незакоммиченного члена), а автозамена ∥ подхват оставляет привязку заменённого документа | тихо потерянная привязка документа (редко, чинится повторным добавлением ЮЛ в периметр) | +| 13 | 2026-10-06 | R-3: `lock_document`, `lock_file_documents` (и `lock_file` при участии в тех же циклах) берут `FOR NO KEY UPDATE` (`with_for_update(key_share=True)`) | вставка `consolidation_document` берёт `FOR KEY SHARE` на документ, а `FOR UPDATE` подтверждения конфликтует с ней: confirm ∥ правка периметра/подхват взаимоблокируются | нулевая: для остальных путей блокировка писателей не меняется, слабее только по FK-ссылкам | +| 14 | 2026-10-06 | R-4: `Resolution(entity_id, kind)` определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` импортирует и реэкспортирует | `domain/consolidated_osv.py` (mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`, домен не импортирует репозитории | нулевая (перенос определения) | +| 15 | 2026-10-06 | R-5: Task 2 создаёт `domain/checklist.py` с `EntityFilesState(has_in_progress, last_file_status)` и `entity_files_state(session, entity_ids)`; `unconfirmed_documents(...)` и `build` добавляет Task 8 | план описывал `entity_files_state` и в Task 2, и в Task 8; `build` получает и `files_state`, и `unbound` | нулевая (разнесение по задачам) | -- 2.47.2 From eaee3ec5f94d94c469c04eaad15779b7f0b1924d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:23:02 +0400 Subject: [PATCH 04/27] =?UTF-8?q?feat(model):=20DBML=20v5.16=20=E2=80=94?= =?UTF-8?q?=20counterparty=5Fmatch=5Fkind,=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=200004,=20=D1=88=D0=B0=D0=B3=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B0=20=D0=B2=20=D0=BC=D0=B0?= =?UTF-8?q?=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enum counterparty_match_kind (INN, NAME, ALIAS, MANUAL, PROPAGATED) и nullable-колонка osv_entry.counterparty_match_kind (NULL <=> counterparty_id NULL). Миграция 0004_dbml_v5_16: enum, колонка, backfill тремя UPDATE по уже смапленным строкам (ИНН без пробелов -> INN, trim(имя) -> NAME, остальные -> ALIAS), откат удаляет колонку и тип. CounterpartyIndex.resolve возвращает Resolution(entity_id, kind) (определение в domain/osv_mapping, R-4); маппинг пишет kind; EntryOut.match_kind. DBML_VERSION 5.16, Makefile/dbml_parity/CLAUDE/README/config.yaml. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 8 +- api/alembic/versions/0004_dbml_v5_16.py | 57 +++++++++ api/scripts/check_dbml_vs_orm.py | 2 +- api/scripts/dbml_parity.py | 2 +- api/src/domain/osv_mapping.py | 24 +++- api/src/models/__init__.py | 4 +- api/src/models/access.py | 2 +- api/src/models/accounting.py | 2 +- api/src/models/audit_ex.py | 2 +- api/src/models/base.py | 2 +- api/src/models/consolidation.py | 2 +- api/src/models/directories.py | 2 +- api/src/models/enums.py | 19 ++- api/src/models/files.py | 2 +- api/src/models/kfo.py | 2 +- api/src/models/osv.py | 6 +- api/src/models/statements.py | 2 +- api/src/repositories/counterparties.py | 14 ++- api/src/schemas/documents.py | 3 + api/src/services/result_mapping.py | 8 +- api/tests/test_api_documents.py | 29 +++++ api/tests/test_check_dbml_script.py | 12 +- api/tests/test_counterparty_resolve.py | 48 +++++-- api/tests/test_domain_osv_mapping.py | 1 + api/tests/test_enums.py | 11 +- api/tests/test_mapping_golden.py | 6 +- api/tests/test_migrations.py | 118 +++++++++++++++++- api/tests/test_models_part_07_09.py | 10 +- api/tests/test_result_mapping.py | 7 ++ .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 1 + .../changes/slice-4-consolidation/tasks.md | 2 +- openspec/config.yaml | 4 +- 35 files changed, 355 insertions(+), 69 deletions(-) create mode 100644 api/alembic/versions/0004_dbml_v5_16.py diff --git a/CLAUDE.md b/CLAUDE.md index 8bc8261..4ed7552 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,7 +104,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.15.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.16.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/Makefile b/Makefile index 37a7da3..02222ac 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.15.dbml +DBML ?= ../../docs/model/data-model-v5.16.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index f3f15e1..65c0f14 100644 --- a/README.md +++ b/README.md @@ -673,7 +673,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.15.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.16.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -724,7 +724,7 @@ queue group `arm-core`, подписчики группы получают со ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.16.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -747,7 +747,7 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) @@ -822,7 +822,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.15.dbml`](../docs/model/data-model-v5.15.dbml) — модель данных; + [`../docs/model/data-model-v5.16.dbml`](../docs/model/data-model-v5.16.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0004_dbml_v5_16.py b/api/alembic/versions/0004_dbml_v5_16.py new file mode 100644 index 0000000..1536b9c --- /dev/null +++ b/api/alembic/versions/0004_dbml_v5_16.py @@ -0,0 +1,57 @@ +"""DBML v5.16: enum counterparty_match_kind и osv_entry.counterparty_match_kind (Срез 4, design D-4). + +Колонка — способ сопоставления контрагента строки ОСВ (`NULL` ⇔ `counterparty_id` `NULL`). Backfill по уже +смапленным строкам приближённый: ручных сопоставлений до Среза 4 не было, поэтому `counterparty_id IS NOT NULL` +без совпадения по ИНН и точному имени — `ALIAS` (нормализованное имя ЮЛ или алиас). Порядок проверок — как в +`CounterpartyIndex.resolve`: ИНН → точное имя → остальное. Колонка добавляется последней (в ORM она тоже последняя). + +Revision ID: 0004_dbml_v5_16 +Revises: 0003_dbml_v5_15 +Create Date: 2026-10-06 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0004_dbml_v5_16" +down_revision: str | Sequence[str] | None = "0003_dbml_v5_15" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +# порядок значений — как в DBML v5.16 (сверка enum'ов сравнивает упорядоченные списки) +MATCH_KINDS = ("INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED") + +# raw-строки: `\s` должен дойти до PostgreSQL как есть (в обычной python-строке это недопустимая escape) +_BACKFILL_INN = rf""" +UPDATE {SCHEMA}.osv_entry e SET counterparty_match_kind = 'INN' +FROM {SCHEMA}.legal_entity l +WHERE e.counterparty_id = l.id AND e.counterparty_match_kind IS NULL + AND regexp_replace(coalesce(e.counterparty_inn, ''), '\s', '', 'g') = l.inn +""" +_BACKFILL_NAME = rf""" +UPDATE {SCHEMA}.osv_entry e SET counterparty_match_kind = 'NAME' +FROM {SCHEMA}.legal_entity l +WHERE e.counterparty_id = l.id AND e.counterparty_match_kind IS NULL + AND btrim(e.counterparty_name) = l.name +""" +_BACKFILL_ALIAS = rf""" +UPDATE {SCHEMA}.osv_entry e SET counterparty_match_kind = 'ALIAS' +FROM {SCHEMA}.legal_entity l +WHERE e.counterparty_id = l.id AND e.counterparty_match_kind IS NULL + AND e.counterparty_id IS NOT NULL +""" + + +def upgrade() -> None: + values = ", ".join(f"'{kind}'" for kind in MATCH_KINDS) + op.execute(f"CREATE TYPE {SCHEMA}.counterparty_match_kind AS ENUM ({values})") + op.execute(f"ALTER TABLE {SCHEMA}.osv_entry ADD COLUMN counterparty_match_kind {SCHEMA}.counterparty_match_kind") + for statement in (_BACKFILL_INN, _BACKFILL_NAME, _BACKFILL_ALIAS): + op.execute(statement) + + +def downgrade() -> None: + op.execute(f"ALTER TABLE {SCHEMA}.osv_entry DROP COLUMN counterparty_match_kind") + op.execute(f"DROP TYPE {SCHEMA}.counterparty_match_kind") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index b198be3..01edc31 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -2,7 +2,7 @@ Запуск из `api/`: - python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.15.dbml + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.16.dbml Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 16da494..6450506 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.functions import FunctionElement SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.15.dbml" +DBML_FILE_NAME = "data-model-v5.16.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 743b0b1..06119e0 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -3,7 +3,8 @@ Чистые функции без БД, S3 и сессии (spec osv-result-mapping; design D-6…D-9): нормализация кода счёта, строки ОСВ с деревом по `parent_row_number`, поля документа из шапки, проверка контрагента шапки, статус документа и сверка сальдо на начало с Q1 для признака нарастающей. Резолв контрагента строки и запись в БД — в цикле маппинга -(`services/result_mapping.py`), он проставляет `EntryDraft.counterparty_id` через `dataclasses.replace`. +(`services/result_mapping.py`), он проставляет `EntryDraft.counterparty_id` и `counterparty_match_kind` через +`dataclasses.replace`. Суммы — `Decimal` из контракта без изменений, `float` не используется. """ @@ -15,7 +16,14 @@ from decimal import Decimal from src.contract.result import OsvChecks, OsvDocument, OsvHeader, OsvRow from src.domain.aliases import normalize_alias -from src.models.enums import DocumentCheckResult, DocumentStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.enums import ( + CounterpartyMatchKind, + DocumentCheckResult, + DocumentStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, +) __all__ = [ "ACCOUNT_SEGMENT_DIGITS", @@ -23,6 +31,7 @@ __all__ = [ "DocumentDraft", "EntryDraft", "MappedSheet", + "Resolution", "account_root", "build_entries", "counterparty_check", @@ -44,6 +53,14 @@ _NON_DIGITS_RE = re.compile(r"[^0-9]") _SEGMENT_SEPARATOR = "." +@dataclass(frozen=True) +class Resolution: + """Результат резолва контрагента (§3 п. 4): ЮЛ справочника и шаг, на котором он найден (R-4: живёт в домене).""" + + entity_id: uuid.UUID + kind: CounterpartyMatchKind + + @dataclass(frozen=True) class EntryDraft: """Черновик `osv_entry`: id выдаётся заранее (`uuid4`), чтобы связать дерево до вставки.""" @@ -64,6 +81,7 @@ class EntryDraft: # opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit sums: tuple[Decimal | None, ...] counterparty_id: uuid.UUID | None = None # проставляет цикл маппинга (резолв §3 п. 4), не build_entries + counterparty_match_kind: CounterpartyMatchKind | None = None # v5.16: шаг резолва; NULL ⇔ counterparty_id NULL @dataclass(frozen=True) @@ -78,7 +96,7 @@ class MappedSheet: @dataclass(frozen=True) class DocumentDraft: - """Поля `document` (DBML v5.15), которые задаёт шапка листа ОСВ; остальное (ЮЛ, файл, статус) — вызывающий.""" + """Поля `document` (DBML v5.16), которые задаёт шапка листа ОСВ; остальное (ЮЛ, файл, статус) — вызывающий.""" osv_account_code: str period_year: int | None diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 0fbdc36..f951554 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.15.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.16.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.15" +DBML_VERSION = "5.16" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/access.py b/api/src/models/access.py index 3bb83ee..52ba38a 100644 --- a/api/src/models/access.py +++ b/api/src/models/access.py @@ -1,4 +1,4 @@ -"""Часть 02 DBML v5.15 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" +"""Часть 02 DBML v5.16 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" import datetime import uuid diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py index 5bc3ed5..153266d 100644 --- a/api/src/models/accounting.py +++ b/api/src/models/accounting.py @@ -1,4 +1,4 @@ -"""Часть 03 DBML v5.15 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" +"""Часть 03 DBML v5.16 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" import datetime import uuid diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 53e3723..27ce4ee 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -1,4 +1,4 @@ -"""Часть 09 DBML v5.15 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. +"""Часть 09 DBML v5.16 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. `audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` diff --git a/api/src/models/base.py b/api/src/models/base.py index e5cbbbc..5f768c4 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -6,7 +6,7 @@ from sqlalchemy import MetaData from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column -#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.15). +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.16). SCHEMA = "dkb" #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 3e378f0..9a990a1 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -1,4 +1,4 @@ -"""Часть 07 DBML v5.15 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. +"""Часть 07 DBML v5.16 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из `osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 183ea2b..cab6181 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,4 +1,4 @@ -"""Часть 01 DBML v5.15 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.16 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 464c20d..7bb613b 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ -"""Enum'ы модели данных DBML v5.15 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.16 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.15.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.16.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. @@ -146,6 +146,20 @@ class OsvGroupType(enum.StrEnum): OTHER = "OTHER" +class CounterpartyMatchKind(enum.StrEnum): + """DBML `counterparty_match_kind` (v5.16): способ сопоставления контрагента строки ОСВ. + + `INN` / `NAME` / `ALIAS` — шаг автоматического резолва при маппинге; `MANUAL` — ручное сопоставление; + `PROPAGATED` — автоприменение по имени в версии (DOC-221278 п. 12–13). + """ + + INN = "INN" + NAME = "NAME" + ALIAS = "ALIAS" + MANUAL = "MANUAL" + PROPAGATED = "PROPAGATED" + + class OsvDataType(enum.StrEnum): """DBML `osv_data_type`.""" @@ -311,6 +325,7 @@ ENUMS: dict[str, type[enum.Enum]] = { "recognition_status": RecognitionStatus, "statement_type": StatementType, "osv_group_type": OsvGroupType, + "counterparty_match_kind": CounterpartyMatchKind, "osv_data_type": OsvDataType, "consolidation_scenario": ConsolidationScenario, "consolidation_approach": ConsolidationApproach, diff --git a/api/src/models/files.py b/api/src/models/files.py index b856173..4139b31 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.15 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.16 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index 1fa8061..cfaee70 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.15 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.16 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index fbebe82..5276811 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -1,4 +1,4 @@ -"""Часть 05 DBML v5.15 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). +"""Часть 05 DBML v5.16 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом (`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). @@ -14,7 +14,7 @@ from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from .base import Base, UUIDPrimaryMixin, nullable_jsonb -from .enums import OsvDataType, OsvGroupType, pg_enum +from .enums import CounterpartyMatchKind, OsvDataType, OsvGroupType, pg_enum class OsvEntry(UUIDPrimaryMixin, Base): @@ -60,3 +60,5 @@ class OsvEntry(UUIDPrimaryMixin, Base): modified_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) modified_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) is_excisable: Mapped[bool | None] = mapped_column(Boolean) + # v5.16, последней колонкой (как её добавляет миграция 0004): NULL ⇔ counterparty_id NULL; проверяет ядро + counterparty_match_kind: Mapped[CounterpartyMatchKind | None] = mapped_column(pg_enum("counterparty_match_kind")) diff --git a/api/src/models/statements.py b/api/src/models/statements.py index 3f81214..f2f756f 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.15 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.16 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/repositories/counterparties.py b/api/src/repositories/counterparties.py index 638d0f1..9f9a4ec 100644 --- a/api/src/repositories/counterparties.py +++ b/api/src/repositories/counterparties.py @@ -15,7 +15,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.aliases import normalize_alias from src.domain.inn import normalize_inn +from src.domain.osv_mapping import Resolution from src.models.directories import LegalEntity, LegalEntityAlias +from src.models.enums import CounterpartyMatchKind + +__all__ = ["CounterpartyIndex", "Resolution"] # `Resolution` определён в домене (R-4), здесь реэкспортируется @dataclass @@ -54,20 +58,22 @@ class CounterpartyIndex: aliases_by_entity=dict(aliases_by_entity), ) - def resolve(self, inn: str | None, name: str | None) -> uuid.UUID | None: + def resolve(self, inn: str | None, name: str | None) -> Resolution | None: """§3 п. 4: ИНН → точное имя → нормализованное имя/алиас; больше одного кандидата на шаге → `None`. + Шаг попадает в результат: ИНН → `INN`, точное имя → `NAME`, нормализованное имя ЮЛ или алиас → `ALIAS`. + ИНН, которого нет в справочнике, не блокирует поиск по имени (Review Focus 3). Ноль кандидатов на шаге — переход к следующему; больше одного — ручное сопоставление, дальше не идём. """ if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None: - return found + return Resolution(found, CounterpartyMatchKind.INN) if not name or not (stripped := name.strip()): return None if candidates := self.by_exact_name.get(stripped): - return next(iter(candidates)) if len(candidates) == 1 else None + return Resolution(next(iter(candidates)), CounterpartyMatchKind.NAME) if len(candidates) == 1 else None if (normalized := normalize_alias(stripped)) and (candidates := self.by_normalized.get(normalized)): - return next(iter(candidates)) if len(candidates) == 1 else None + return Resolution(next(iter(candidates)), CounterpartyMatchKind.ALIAS) if len(candidates) == 1 else None return None def owner_names(self, entity: LegalEntity) -> frozenset[str]: diff --git a/api/src/schemas/documents.py b/api/src/schemas/documents.py index b66721f..9090be8 100644 --- a/api/src/schemas/documents.py +++ b/api/src/schemas/documents.py @@ -13,6 +13,7 @@ from decimal import Decimal from pydantic import BaseModel, ConfigDict, Field from src.models.enums import ( + CounterpartyMatchKind, DocumentCheckResult, DocumentStatus, DocumentType, @@ -73,6 +74,8 @@ class EntryOut(BaseModel): counterparty_name: str | None counterparty_inn: str | None counterparty_id: uuid.UUID | None + # способ сопоставления контрагента (v5.16); в API — `match_kind`, в ORM — `counterparty_match_kind` + match_kind: CounterpartyMatchKind | None = Field(validation_alias="counterparty_match_kind") opening_debit: Decimal | None opening_credit: Decimal | None turnover_debit: Decimal | None diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index dcb1010..1ec0abe 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -217,13 +217,16 @@ def _with_counterparty(entry: EntryDraft, index: CounterpartyIndex) -> EntryDraf """R-3: `counterparty_id` — только строкам `COUNTERPARTY` с `data_type ∈ {BU, SUM}` (§3 п. 4, без policy).""" if entry.group_type is not OsvGroupType.COUNTERPARTY or entry.data_type not in RESOLVED_DATA_TYPES: return entry - return dataclasses.replace(entry, counterparty_id=index.resolve(entry.counterparty_inn, entry.counterparty_name)) + resolution = index.resolve(entry.counterparty_inn, entry.counterparty_name) + if resolution is None: + return entry + return dataclasses.replace(entry, counterparty_id=resolution.entity_id, counterparty_match_kind=resolution.kind) def _entry_values( entry: EntryDraft, *, document_id: uuid.UUID, job_id: uuid.UUID, owner_id: uuid.UUID ) -> dict[str, Any]: - """Строка `osv_entry` (DBML v5.15) из черновика; суммы — `Decimal` из контракта без изменений.""" + """Строка `osv_entry` (DBML v5.16) из черновика; суммы — `Decimal` из контракта без изменений.""" opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit = entry.sums return { "id": entry.id, @@ -243,6 +246,7 @@ def _entry_values( "counterparty_name": entry.counterparty_name, "counterparty_inn": entry.counterparty_inn, "counterparty_id": entry.counterparty_id, + "counterparty_match_kind": entry.counterparty_match_kind, "opening_debit": opening_debit, "opening_credit": opening_credit, "turnover_debit": turnover_debit, diff --git a/api/tests/test_api_documents.py b/api/tests/test_api_documents.py index c56f5fe..722391e 100644 --- a/api/tests/test_api_documents.py +++ b/api/tests/test_api_documents.py @@ -23,12 +23,14 @@ from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, AuditSection, + CounterpartyMatchKind, DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile +from src.models.osv import OsvEntry from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -209,6 +211,7 @@ async def test_entries_as_tree_on_golden_osv_66( "counterparty_name", "counterparty_inn", "counterparty_id", + "match_kind", "opening_debit", "opening_credit", "turnover_debit", @@ -218,6 +221,32 @@ async def test_entries_as_tree_on_golden_osv_66( } +async def test_entries_expose_match_kind(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """v5.16: `match_kind` — способ сопоставления контрагента; у несопоставленной строки `null`.""" + entity = await _rostov_entity(seeded) + counterparty = await make_legal_entity(seeded, name="ООО Контрагент Вид") + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + result = json.loads( + (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-minimal.json").read_text("utf-8") + ) + (document,) = await make_entries_from_result(seeded, file, job, result) + await seeded.execute( + update(OsvEntry) + .where(OsvEntry.document_id == document.id, OsvEntry.row_number == 2) # noqa: PLR2004 + .values(counterparty_id=counterparty.id, counterparty_match_kind=CounterpartyMatchKind.MANUAL) + ) + client = await auth_client(KA_RF) + + rows = (await client.get(f"/documents/{document.id}/entries")).json()["items"] + + assert [(row["row_number"], row["counterparty_id"], row["match_kind"]) for row in rows] == [ + (1, None, None), + (2, str(counterparty.id), "MANUAL"), + ] + + async def test_entries_pagination_and_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, job = await make_stored_file( diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 39c06a2..887b74e 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.16.dbml" + newer = tmp_path / "data-model-v5.17.dbml" # шапка 5.16 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.15", "v5.16") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели"]) + newer.write_text(lines[0].replace("v5.16", "v5.17") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.17 не совпадает с версией ORM 5.16: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.16.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.15", "v5.16", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.17.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.16", "v5.17", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.16 не совпадает с версией ORM 5.15: обновите модели\n" + assert result.stdout == "версия DBML 5.17 не совпадает с версией ORM 5.16: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_counterparty_resolve.py b/api/tests/test_counterparty_resolve.py index b0b0a6e..fa6e773 100644 --- a/api/tests/test_counterparty_resolve.py +++ b/api/tests/test_counterparty_resolve.py @@ -5,18 +5,25 @@ """ import datetime +import uuid import pytest from sqlalchemy.ext.asyncio import AsyncSession from src.domain.aliases import normalize_alias from src.models.directories import LegalEntityAlias -from src.repositories.counterparties import CounterpartyIndex +from src.models.enums import CounterpartyMatchKind +from src.repositories.counterparties import CounterpartyIndex, Resolution from tests.factories import make_legal_entity pytestmark = pytest.mark.integration +def _entity(resolution: Resolution | None) -> uuid.UUID | None: + """Идентификатор ЮЛ из результата резолва (шаг резолва проверяют отдельные тесты).""" + return resolution.entity_id if resolution is not None else None + + async def _alias(session: AsyncSession, entity_id, alias: str) -> None: session.add(LegalEntityAlias(legal_entity_id=entity_id, alias=alias, normalized_alias=normalize_alias(alias))) await session.flush() @@ -32,8 +39,8 @@ async def test_inn_alias_and_ambiguous(db_session: AsyncSession) -> None: index = await CounterpartyIndex.load(db_session) - assert index.resolve("3528052718", "что угодно") == by_inn.id - assert index.resolve(None, "Русский бисквит") == by_alias.id + assert _entity(index.resolve("3528052718", "что угодно")) == by_inn.id + assert _entity(index.resolve(None, "Русский бисквит")) == by_alias.id # точного имени нет, два ЮЛ с нормализованным именем `альфаомега` assert index.resolve(None, "ООО Альфаомега") is None @@ -42,7 +49,7 @@ async def test_inn_is_compared_without_whitespace(db_session: AsyncSession) -> N entity = await make_legal_entity(db_session, name="ООО Пробел", inn="7701234567") index = await CounterpartyIndex.load(db_session) - assert index.resolve(" 7701 234 567 ", None) == entity.id + assert _entity(index.resolve(" 7701 234 567 ", None)) == entity.id assert index.resolve("7701234568", None) is None assert index.resolve("", "") is None assert index.resolve(None, None) is None @@ -55,7 +62,7 @@ async def test_unknown_inn_falls_through_to_alias_of_other_entity(db_session: As await _alias(db_session, owner.id, "Пекарня Счастливочная") index = await CounterpartyIndex.load(db_session) - assert index.resolve("9999999999", "Пекарня Счастливочная ООО") == owner.id + assert _entity(index.resolve("9999999999", "Пекарня Счастливочная ООО")) == owner.id async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None: @@ -64,7 +71,7 @@ async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None await make_legal_entity(db_session, name="ООО Дубль Имени", inn="2222222222") index = await CounterpartyIndex.load(db_session) - assert index.resolve(None, " ООО Точное Имя Один ") == unique.id + assert _entity(index.resolve(None, " ООО Точное Имя Один ")) == unique.id # >1 кандидата на шаге точного имени → None, без перехода дальше assert index.resolve(None, "ООО Дубль Имени") is None @@ -76,7 +83,7 @@ async def test_unique_exact_name_wins_over_ambiguous_normalized(db_session: Asyn await make_legal_entity(db_session, name="АО Альфа Приоритет") index = await CounterpartyIndex.load(db_session) - assert index.resolve(None, "ООО Альфа Приоритет") == ooo.id + assert _entity(index.resolve(None, "ООО Альфа Приоритет")) == ooo.id # без точного совпадения нормализованное имя неоднозначно → None assert index.resolve(None, "Альфа Приоритет") is None @@ -85,7 +92,7 @@ async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSe entity = await make_legal_entity(db_session, name="ООО «Нормализуемое Имя»") index = await CounterpartyIndex.load(db_session) - assert index.resolve(None, "Нормализуемое имя, ООО") == entity.id + assert _entity(index.resolve(None, "Нормализуемое имя, ООО")) == entity.id async def test_alias_ambiguity_between_name_and_alias_of_different_entities(db_session: AsyncSession) -> None: @@ -103,7 +110,7 @@ async def test_same_entity_by_name_and_alias_is_not_ambiguous(db_session: AsyncS await _alias(db_session, entity.id, "Эпсилон") index = await CounterpartyIndex.load(db_session) - assert index.resolve(None, "эпсилон ооо") == entity.id + assert _entity(index.resolve(None, "эпсилон ооо")) == entity.id async def test_closed_entity_is_resolved(db_session: AsyncSession) -> None: @@ -112,8 +119,8 @@ async def test_closed_entity_is_resolved(db_session: AsyncSession) -> None: ) index = await CounterpartyIndex.load(db_session) - assert index.resolve("3333333333", None) == closed.id - assert index.resolve(None, "закрытое зета, ООО") == closed.id + assert _entity(index.resolve("3333333333", None)) == closed.id + assert _entity(index.resolve(None, "закрытое зета, ООО")) == closed.id async def test_owner_names(db_session: AsyncSession) -> None: @@ -136,3 +143,22 @@ async def test_owner_names_without_aliases_and_empty_normalization(db_session: A assert index.owner_names(plain) == frozenset({"иванов"}) # нормализация в пустую строку не даёт совпадений: пустой ключ в индексе не хранится assert index.resolve(None, "АО") is None + + +async def test_resolution_kind_per_step(db_session: AsyncSession) -> None: + """v5.16: результат резолва несёт шаг — ИНН → `INN`, точное имя → `NAME`, нормализованное имя/алиас → `ALIAS`.""" + by_inn = await make_legal_entity(db_session, name="ООО Шаг Инн", inn="4401000001") + by_name = await make_legal_entity(db_session, name="ООО Шаг Имя", inn="4401000002") + by_normalized = await make_legal_entity(db_session, name="ООО «Шаг Нормализация»", inn="4401000003") + by_alias = await make_legal_entity(db_session, name="ООО Совсем Другое Имя", inn="4401000004") + await _alias(db_session, by_alias.id, "Шаг Алиас") + index = await CounterpartyIndex.load(db_session) + + assert index.resolve("4401000001", "ООО Шаг Имя") == Resolution(by_inn.id, CounterpartyMatchKind.INN) + assert index.resolve(None, " ООО Шаг Имя ") == Resolution(by_name.id, CounterpartyMatchKind.NAME) + # ИНН не найден, точного имени нет, нормализованное — единственное + assert index.resolve("9999999999", "Шаг нормализация, ООО") == Resolution( + by_normalized.id, CounterpartyMatchKind.ALIAS + ) + assert index.resolve(None, "шаг алиас") == Resolution(by_alias.id, CounterpartyMatchKind.ALIAS) + assert index.resolve(None, "Нет такого") is None diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 05c6cb9..7b5cb6a 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -125,6 +125,7 @@ def test_build_entries_on_minimal_example() -> None: assert child.counterparty_name == "ООО Лютик" assert child.counterparty_inn == "7711111111" assert child.counterparty_id is None # R-3: резолв — в цикле маппинга, не здесь + assert child.counterparty_match_kind is None # v5.16: способ сопоставления — оттуда же assert child.sums == ( Decimal("0.00"), Decimal("1000.00"), diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 1eb65cb..0586ba6 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.15: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.16: имена, значения и их порядок (без БД).""" import enum import re @@ -10,12 +10,12 @@ from src.models import DBML_VERSION from src.models.enums import ENUMS, pg_enum from tests.dbml_helpers import dbml_path, load_dbml -EXPECTED_ENUM_COUNT = 27 +EXPECTED_ENUM_COUNT = 28 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.15" + assert DBML_VERSION == "5.16" assert f"v{DBML_VERSION}" in dbml_path().name @@ -24,6 +24,11 @@ def test_osv_data_type_has_nu_after_bu() -> None: assert [m.value for m in ENUMS["osv_data_type"]] == ["BU", "NU", "CURRENCY", "QUANTITY", "SUM"] +def test_counterparty_match_kind_values() -> None: + """v5.16: способ сопоставления контрагента — пять значений в порядке DBML.""" + assert [m.value for m in ENUMS["counterparty_match_kind"]] == ["INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED"] + + def test_enums_count_and_names_match_dbml() -> None: dbml_enums = {e.name for e in load_dbml().enums} assert len(dbml_enums) == EXPECTED_ENUM_COUNT diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 8b95f07..35392e4 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -83,6 +83,8 @@ def _check_sheet( # noqa: PLR0912 - все инварианты одного л for field in SUM_FIELDS: if getattr(entry, field) != _decimal(row[field]): problems.append(f"{where}: {field} {getattr(entry, field)} != {row[field]}") + if (entry.counterparty_id is None) != (entry.counterparty_match_kind is None): + problems.append(f"{where}: counterparty_id {entry.counterparty_id}, kind {entry.counterparty_match_kind}") code = row["account_code_raw"] expected = chart.get(normalize_account_code(code)) if code else None if entry.account_id != expected: @@ -122,7 +124,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P problems: list[str] = [] missing_codes: set[str] = set() - documents_total = entries_total = with_account = 0 + documents_total = entries_total = with_account = with_kind = 0 for path, file_id, _ in mapped: name = str(path.relative_to(fixtures_dir / GOLDEN_DIR)) raw = json.loads(path.read_text("utf-8")) @@ -150,6 +152,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P documents_total += 1 entries_total += len(entries) with_account += sum(1 for entry in entries if entry.account_id is not None) + with_kind += sum(1 for entry in entries if entry.counterparty_match_kind is not None) file_status = (await session.execute(select(StoredFile.status).where(StoredFile.id == file_id))).scalar_one() if file_status not in {FileStatus.PROCESSED, FileStatus.SUPERSEDED}: problems.append(f"{name}: статус файла {file_status}") @@ -160,5 +163,6 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P f"{sorted(missing_codes)}" ) assert problems == [] + assert with_kind > 0 # есть сопоставленные строки: проверка «kind ⇔ counterparty_id» не вырождена assert with_account > 0 # строки со счётом плана счетов есть — сверка account_id не вырождена assert mapping_seconds <= TIME_BUDGET_SECONDS diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index a8e74d0..1510c42 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,5 +1,5 @@ -"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15) на PostgreSQL 16: полный цикл, откат на шаг, пустой -autogenerate, схема = схеме ORM. +"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16) на PostgreSQL 16: полный цикл, откат на шаг, +пустой autogenerate, схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из @@ -15,6 +15,7 @@ import pytest from alembic.autogenerate import compare_metadata from alembic.runtime.migration import MigrationContext from sqlalchemy import Connection, make_url, text +from sqlalchemy.exc import ProgrammingError from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from src.models import Base @@ -35,7 +36,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0003_dbml_v5_15" +HEAD = "0004_dbml_v5_16" +V5_15 = "0003_dbml_v5_15" V5_14 = "0002_dbml_v5_14" PREVIOUS = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" @@ -329,6 +331,112 @@ async def test_upgrade_0003_lowercases_existing_emails(scratch_db_url: str) -> N await engine.dispose() +async def _kind_column(conn: AsyncConnection) -> list[Any]: + return await _scalars( + conn, + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'osv_entry' AND column_name = 'counterparty_match_kind'", + ) + + +@pytest.mark.integration +async def test_upgrade_0004_backfills_counterparty_match_kind(scratch_db_url: str) -> None: + """0004: колонка и enum создаются; смапленные строки получают INN / NAME / ALIAS, без контрагента — NULL; + `downgrade 0003` убирает колонку и тип, повторный upgrade возвращает.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + ids = {k: uuid.uuid4() for k in ("ro", "le", "owner", "s3", "file", "job", "doc", "e1", "e2", "e3", "e4")} + try: + await alembic(scratch_db_url, "upgrade", V5_15) + async with engine.begin() as conn: + assert await _kind_column(conn) == [] + await conn.execute( + text( + "INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES " + "(:le, '7701000001', 'ООО Ромашка', :now), (:owner, '7702000002', 'ООО Владелец', :now)" + ), + {**ids, "now": now}, + ) + await conn.execute( + text( + "INSERT INTO dkb.legal_entity_alias (id, legal_entity_id, alias, normalized_alias) " + "VALUES (:id, :le, 'Ромашка Плюс', 'ромашка плюс')" + ), + {"id": uuid.uuid4(), **ids}, + ) + await conn.execute( + text( + "INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) " + "VALUES (:s3, 'uploads', 'b', 'k', :now)" + ), + {**ids, "now": now}, + ) + await conn.execute( + text( + "INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status) " + "VALUES (:file, :owner, 'XLSX', :s3, 'PROCESSED')" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.recognition_job (id, file_id, pipeline, request_subject, schema_version, status) " + "VALUES (:job, :file, 'tabular', 'recognition.tabular.request', '1', 'queued')" + ), + ids, + ) + await conn.execute( + text( + "INSERT INTO dkb.document " + "(id, stored_file_id, legal_entity_id, document_type, osv_account_code, status) " + "VALUES (:doc, :file, :owner, 'OSV', '62', 'RECOGNIZED')" + ), + ids, + ) + entry = text( + "INSERT INTO dkb.osv_entry (id, recognition_job_id, document_id, owner_legal_entity_id, row_number, " + "counterparty_name, counterparty_inn, counterparty_id) " + "VALUES (:id, :job, :doc, :owner, :n, :name, :inn, :cp)" + ) + base = {"job": ids["job"], "doc": ids["doc"], "owner": ids["owner"]} + rows = [ + # ИНН с пробелами совпал (и имя тоже) → INN имеет приоритет + ("e1", 1, "ООО Ромашка", " 7701 000 001 ", ids["le"]), + # ИНН не совпал, имя совпало после btrim → NAME + ("e2", 2, " ООО Ромашка ", "9999999999", ids["le"]), + # только алиас → ALIAS + ("e3", 3, "Ромашка Плюс", None, ids["le"]), + # контрагент не сопоставлен → NULL + ("e4", 4, "ООО Неизвестный", None, None), + ] + for key, n, name, inn, cp in rows: + await conn.execute(entry, {**base, "id": ids[key], "n": n, "name": name, "inn": inn, "cp": cp}) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + assert await _kind_column(conn) == ["counterparty_match_kind"] + [labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.counterparty_match_kind)::text[]") + assert labels == ["INN", "NAME", "ALIAS", "MANUAL", "PROPAGATED"] + kinds = dict( + (await conn.execute(text("SELECT id, counterparty_match_kind::text FROM dkb.osv_entry"))).all() + ) + assert kinds == {ids["e1"]: "INN", ids["e2"]: "NAME", ids["e3"]: "ALIAS", ids["e4"]: None} + await engine.dispose() + + await alembic(scratch_db_url, "downgrade", V5_15) + async with engine.connect() as conn: + assert await _kind_column(conn) == [] + with pytest.raises(ProgrammingError, match="counterparty_match_kind"): + await conn.execute(text("SELECT enum_range(NULL::dkb.counterparty_match_kind)")) + await engine.dispose() + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + await _assert_full_model(conn) + finally: + await engine.dispose() + + @pytest.mark.integration async def test_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None: """0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы.""" @@ -545,8 +653,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output - # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 — офлайн-SQL доходит до head - for new_version, old_version in ((V5_14, PREVIOUS), (HEAD, V5_14)): + # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 — офлайн-SQL доходит до head + for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (HEAD, V5_15)): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 8a86c5e..f0f751d 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -1,4 +1,4 @@ -"""ORM частей 07–09 DBML v5.15 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" +"""ORM частей 07–09 DBML v5.16 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" import datetime @@ -49,7 +49,7 @@ PART_07_09_TABLES = { } ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES EXPECTED_TABLE_COUNT = 43 -EXPECTED_ENUM_COUNT = 27 +EXPECTED_ENUM_COUNT = 28 def test_part_07_09_tables_are_new() -> None: @@ -112,7 +112,7 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None: def test_all_43_tables_registered_and_match_dbml_names() -> None: - """Состав metadata == таблицы DBML v5.15 (имена буквально), всего 43, все в схеме dkb.""" + """Состав metadata == таблицы DBML v5.16 (имена буквально), всего 43, все в схеме dkb.""" tables = list(Base.metadata.sorted_tables) assert len(tables) == EXPECTED_TABLE_COUNT assert all(t.schema == "dkb" for t in tables) @@ -122,7 +122,7 @@ def test_all_43_tables_registered_and_match_dbml_names() -> None: assert ALL_TABLES == dbml_names # разбиение по частям покрывает модель без пропусков и дублей -def test_all_27_enums_defined() -> None: +def test_all_28_enums_defined() -> None: assert len(ENUMS) == EXPECTED_ENUM_COUNT assert set(ENUMS) == {e.name for e in load_dbml().enums} @@ -137,7 +137,7 @@ def test_orm_matches_dbml_for_all_43_tables() -> None: @pytest.mark.integration async def test_create_all_43_tables_on_scratch_db(scratch_db_url: str) -> None: - """`create_all` всех 43 таблиц; 27 enum-типов в dkb; partial unique `consolidation_lock` работает поведением.""" + """`create_all` всех 43 таблиц; 28 enum-типов в dkb; partial unique `consolidation_lock` работает поведением.""" engine = create_async_engine(scratch_db_url) try: async with engine.begin() as conn: diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 8cf3882..47c87b1 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -33,6 +33,7 @@ from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, + CounterpartyMatchKind, DocumentCheckResult, DocumentStatus, DocumentType, @@ -651,6 +652,12 @@ async def test_counterparty_resolved_in_entries(committed_sessionmaker: async_se assert account.counterparty_id is None assert bu.counterparty_id == lyutik.id assert bu.counterparty_inn == "7711111111" + # v5.16: способ сопоставления хранится ровно у сопоставленных строк (здесь — по ИНН) + assert (bu.counterparty_match_kind, account.counterparty_match_kind, nu.counterparty_match_kind) == ( + CounterpartyMatchKind.INN, + None, + None, + ) assert nu.data_type is OsvDataType.NU assert nu.counterparty_id is None diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 0a2a3be..b128fd3 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -49,9 +49,9 @@ - `@dataclass(frozen=True) class Resolution: entity_id: uuid.UUID; kind: CounterpartyMatchKind` в `repositories/counterparties.py`; `CounterpartyIndex.resolve(inn, name) -> Resolution | None` (ИНН → `INN`; точное имя → `NAME`; нормализованное имя ЮЛ или алиас → `ALIAS`). - Миграция (стиль 0003): `upgrade`: `CREATE TYPE dkb.counterparty_match_kind AS ENUM ('INN','NAME','ALIAS','MANUAL','PROPAGATED')`; `ALTER TABLE dkb.osv_entry ADD COLUMN counterparty_match_kind dkb.counterparty_match_kind`; backfill тремя `UPDATE … FROM dkb.legal_entity l WHERE e.counterparty_id = l.id AND e.counterparty_match_kind IS NULL`: `regexp_replace(coalesce(e.counterparty_inn,''), '\s', '', 'g') = l.inn` → `INN`; `btrim(e.counterparty_name) = l.name` → `NAME`; `e.counterparty_id IS NOT NULL` → `ALIAS`. `downgrade`: `DROP COLUMN`, `DROP TYPE`. -- [ ] **Step 1: Тесты.** `test_enums` (`"5.16"`, 28 enum, значения kind); `test_migrations`: `HEAD`, тест данных — на ревизии 0003 вставить ЮЛ (ИНН `7701000001`, имя `ООО Ромашка`, алиас `ромашка плюс`) и четыре `osv_entry` (ИНН совпал / имя совпало / только алиас / без контрагента) → после `upgrade head` kind = `INN`/`NAME`/`ALIAS`/NULL; `downgrade 0003` → колонки нет, `enum_range(NULL::dkb.counterparty_match_kind)` падает; `test_counterparty_resolve`: `Resolution.kind` для трёх шагов; `test_result_mapping`/`test_mapping_golden`: `counterparty_match_kind` заполнен ⇔ `counterparty_id` заполнен; `test_api_documents.test_entries_*`: `match_kind` в `EntryOut`. -- [ ] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_migrations.py tests/test_result_mapping.py tests/test_api_documents.py -q`; `pytest -m "integration and fixtures" tests/test_mapping_golden.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(model): DBML v5.16 — counterparty_match_kind, миграция 0004, шаг резолва в маппинге` (+ коммит в docs `docs: DBML v5.16, entities-v5.16, guide`). +- [x] **Step 1: Тесты.** `test_enums` (`"5.16"`, 28 enum, значения kind); `test_migrations`: `HEAD`, тест данных — на ревизии 0003 вставить ЮЛ (ИНН `7701000001`, имя `ООО Ромашка`, алиас `ромашка плюс`) и четыре `osv_entry` (ИНН совпал / имя совпало / только алиас / без контрагента) → после `upgrade head` kind = `INN`/`NAME`/`ALIAS`/NULL; `downgrade 0003` → колонки нет, `enum_range(NULL::dkb.counterparty_match_kind)` падает; `test_counterparty_resolve`: `Resolution.kind` для трёх шагов; `test_result_mapping`/`test_mapping_golden`: `counterparty_match_kind` заполнен ⇔ `counterparty_id` заполнен; `test_api_documents.test_entries_*`: `match_kind` в `EntryOut`. +- [x] **Step 2:** падает → **Step 3:** реализация + docs-правки → **Step 4:** `make dbml-check` пуст; `make migration-cycle`; `make check`; `pytest -m integration tests/test_migrations.py tests/test_result_mapping.py tests/test_api_documents.py -q`; `pytest -m "integration and fixtures" tests/test_mapping_golden.py -q` → PASS. +- [x] **Step 5: Commit** `feat(model): DBML v5.16 — counterparty_match_kind, миграция 0004, шаг резолва в маппинге` (+ коммит в docs `docs: DBML v5.16, entities-v5.16, guide`). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 4e4a3cc..52acf20 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -200,3 +200,4 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 13 | 2026-10-06 | R-3: `lock_document`, `lock_file_documents` (и `lock_file` при участии в тех же циклах) берут `FOR NO KEY UPDATE` (`with_for_update(key_share=True)`) | вставка `consolidation_document` берёт `FOR KEY SHARE` на документ, а `FOR UPDATE` подтверждения конфликтует с ней: confirm ∥ правка периметра/подхват взаимоблокируются | нулевая: для остальных путей блокировка писателей не меняется, слабее только по FK-ссылкам | | 14 | 2026-10-06 | R-4: `Resolution(entity_id, kind)` определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` импортирует и реэкспортирует | `domain/consolidated_osv.py` (mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`, домен не импортирует репозитории | нулевая (перенос определения) | | 15 | 2026-10-06 | R-5: Task 2 создаёт `domain/checklist.py` с `EntityFilesState(has_in_progress, last_file_status)` и `entity_files_state(session, entity_ids)`; `unconfirmed_documents(...)` и `build` добавляет Task 8 | план описывал `entity_files_state` и в Task 2, и в Task 8; `build` получает и `files_state`, и `unbound` | нулевая (разнесение по задачам) | +| 16 | 2026-10-06 | `osv_entry.counterparty_match_kind` — последняя колонка таблицы (в ORM и DBML), а не сразу после `counterparty_id` | `ADD COLUMN` в миграции 0004 добавляет колонку в конец, а `test_migrated_schema_equals_orm_create_all` сравнивает `ordinal_position`; пересоздание таблицы с 200 тыс. строк ради порядка колонок не оправдано | косметика: порядок колонок в `SELECT *`/диаграмме; на поведение не влияет | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 9fd140a..0b7217b 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -6,7 +6,7 @@ ## 1. Модель -- [ ] 1.1 **DBML v5.16, миграция 0004, `match_kind` в маппинге.** `CounterpartyMatchKind` enum, колонка +- [x] 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; diff --git a/openspec/config.yaml b/openspec/config.yaml index 0ef971c..f7f5c7e 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -3,7 +3,7 @@ schema: spec-driven context: | Репозиторий arm-core — часть системы АРМ КА (ДКБ, Россельхозбанк): ядро (FastAPI + React), единственный писатель в PostgreSQL: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, согласование, ЕХ, аудит, права. Workspace из нескольких репозиториев; общий контекст — ../CLAUDE.md и ../docs/ (репозиторий arm-ka-docs). - Источники истины по приоритету: ../docs/model/data-model-v5.13.dbml → ../docs/model/entities-v5.13.md → + Источники истины по приоритету: ../docs/model/data-model-v5.16.dbml → ../docs/model/entities-v5.16.md → ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). @@ -14,7 +14,7 @@ context: | rules: proposal: - Указывать номер среза из 09-delivery-plan.md и затронутые репозитории; сквозные изменения — отдельный change в ../docs - - Перечислять таблицы DBML v5.13, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML + - Перечислять таблицы DBML v5.16, которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML - Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md - Раздел «Non-goals» обязателен - Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn) -- 2.47.2 From d3c37db1970fedd4ed9e557b16834ae47f0753e7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:46:15 +0400 Subject: [PATCH 05/27] =?UTF-8?q?feat(consolidation):=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B9,=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D1=84=D0=B0=D0=B1=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Репозиторий консолидаций (get_version/list_periods/lock_versions_for_entity с блокировкой надмножества версий по R-2, load_osv_rows, entity_files_state), схемы API (D-16), domain/checklist.EntityFilesState (R-5), фабрики make_consolidation/make_lock/make_confirmed_osv и очистка таблиц консолидации в CommittedJobs.cleanup(). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/checklist.py | 19 + api/src/repositories/consolidations.py | 237 +++++++++++ api/src/schemas/consolidations.py | 257 +++++++++++ api/tests/factories.py | 235 ++++++++++ api/tests/test_factories.py | 181 ++++++++ api/tests/test_repositories_consolidations.py | 401 ++++++++++++++++++ api/tests/test_schemas_consolidations.py | 81 ++++ .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/tasks.md | 2 +- 9 files changed, 1415 insertions(+), 4 deletions(-) create mode 100644 api/src/domain/checklist.py create mode 100644 api/src/repositories/consolidations.py create mode 100644 api/src/schemas/consolidations.py create mode 100644 api/tests/test_repositories_consolidations.py create mode 100644 api/tests/test_schemas_consolidations.py diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py new file mode 100644 index 0000000..a8e97b9 --- /dev/null +++ b/api/src/domain/checklist.py @@ -0,0 +1,19 @@ +"""Чек-лист комплекта документов версии консолидации (spec consolidations-api «Чек-лист комплекта»; §9.1). + +Чистые функции без БД и ORM (design D-13). Сейчас — только состояние файлов ЮЛ, которое репозиторий отдаёт +вычислению ячейки (`PROCESSING` — есть файл в работе, `ERROR` — последний файл ЮЛ в ошибке); неподтверждённые +документы-кандидаты и сборка ячеек добавляются в задаче 3.3 (рулинг R-5). +""" + +from dataclasses import dataclass + +from src.models.enums import FileStatus + + +@dataclass(frozen=True) +class EntityFilesState: + """Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус + последнего по `uploaded_at` файла ЮЛ (`None` — файлов нет).""" + + has_in_progress: bool + last_file_status: FileStatus | None diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py new file mode 100644 index 0000000..5faba2b --- /dev/null +++ b/api/src/repositories/consolidations.py @@ -0,0 +1,237 @@ +"""Репозиторий консолидаций (Срез 4; spec consolidations-api, consolidated-osv; design D-1, D-5, D-13). + +Функции не делают `commit` и, кроме `get_version`, `get_consolidation_for_group` и `list_periods` (они получают +`VisibilityScope` и скрывают ГК чужого филиала), не применяют policy: проверку видимости делает вызывающий сервис. +Порядок блокировок строк — файл → документ → версии; `lock_versions_for_entity` берёт версии пачкой по `id`. +""" + +import uuid +from collections import defaultdict +from collections.abc import Collection, Sequence + +from sqlalchemy import Select, exists, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.checklist import EntityFilesState +from src.domain.policy import VisibilityScope, company_groups_visible +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.files import Document, StoredFile +from src.models.osv import OsvEntry + +FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) +SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM) + + +def _visible_consolidations(scope: VisibilityScope) -> Select[tuple[Consolidation]]: + """`SELECT consolidation` с присоединённой ГК и фильтром видимости (§10.1).""" + stmt = select(Consolidation).join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id) + return company_groups_visible(stmt, scope) + + +async def get_version( + session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID +) -> tuple[ConsolidationVersion, Consolidation] | None: + """Версия и её консолидация, если ГК видна пользователю (`consolidation` → `company_group`); иначе `None`.""" + stmt = ( + select(ConsolidationVersion, Consolidation) + .join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id) + .join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id) + .where(ConsolidationVersion.id == version_id) + ) + row = (await session.execute(company_groups_visible(stmt, scope))).first() + return None if row is None else (row[0], row[1]) + + +async def get_consolidation_for_group( + session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter +) -> Consolidation | None: + """Консолидация ГК за период (уникальна по `(ГК, год, квартал)`), если ГК видна пользователю.""" + stmt = _visible_consolidations(scope).where( + Consolidation.company_group_id == group_id, Consolidation.year == year, Consolidation.quarter == quarter + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def list_periods( + session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID +) -> list[tuple[Consolidation, list[ConsolidationVersion]]]: + """Периоды ГК по убыванию года и квартала; версии каждого — по убыванию `version_no`. Двумя запросами.""" + stmt = ( + _visible_consolidations(scope) + .where(Consolidation.company_group_id == group_id) + .order_by(Consolidation.year.desc(), Consolidation.quarter.desc()) + ) + consolidations = list((await session.execute(stmt)).scalars()) + if not consolidations: + return [] + versions: dict[uuid.UUID, list[ConsolidationVersion]] = defaultdict(list) + rows = await session.execute( + select(ConsolidationVersion) + .where(ConsolidationVersion.consolidation_id.in_([c.id for c in consolidations])) + .order_by(ConsolidationVersion.version_no.desc()) + ) + for version in rows.scalars(): + versions[version.consolidation_id].append(version) + return [(consolidation, versions[consolidation.id]) for consolidation in consolidations] + + +async def lock_version(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationVersion | None: + """Версия под `FOR UPDATE`; `populate_existing` — состояние строки в сессии заменяется свежим из БД.""" + stmt = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.id == version_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def lock_versions_for_entity( + session: AsyncSession, entity_id: uuid.UUID, statuses: Collection[ConsolidationStatus] +) -> list[ConsolidationVersion]: + """Версии со статусом из `statuses`, где ЮЛ — член периметра; блокируются `FOR UPDATE ORDER BY id` (R-2). + + Блокируется НАДМНОЖЕСТВО: версии этих статусов, где ЮЛ — член ИЛИ `consolidation.company_group_id` равен ГК ЮЛ + (`legal_entity.company_group_id`). Иначе подтверждение документа ∥ `PUT …/members` теряет привязку: подтверждение + не видит ещё не зафиксированного нового члена и не ждёт версию, которую правит периметр. Блокировка версии ГК, + где ЮЛ формально не член, заставляет такую правку дождаться нас и закоммитить периметр до того, как мы его + перечитаем. Возвращаются только версии, где ЮЛ член, — отдельным вторым запросом: в READ COMMITTED он получает + свежий снимок (уже видит периметры, закоммиченные ожидавшимися транзакциями); блокировка на нём не нужна, строки + заблокированы первым запросом. `populate_existing` — состояние версий в сессии заменяется свежим. + """ + is_member = exists().where( + ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, + ConsolidationMember.legal_entity_id == entity_id, + ) + entity_group = select(LegalEntity.company_group_id).where(LegalEntity.id == entity_id).scalar_subquery() + superset = ( + select(ConsolidationVersion.id) + .join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id) + .where( + ConsolidationVersion.status.in_(statuses), + is_member | (Consolidation.company_group_id == entity_group), + ) + .order_by(ConsolidationVersion.id) + .with_for_update(of=ConsolidationVersion) + ) + locked_ids = list((await session.execute(superset)).scalars()) + if not locked_ids: + return [] + members_only = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.id.in_(locked_ids), is_member) + .order_by(ConsolidationVersion.id) + .execution_options(populate_existing=True) + ) + return list((await session.execute(members_only)).scalars()) + + +async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationLock | None: + """Активная блокировка версии (`released_at IS NULL`; не более одной — partial UNIQUE).""" + stmt = select(ConsolidationLock).where( + ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[ConsolidationMember, LegalEntity]]: + """Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка).""" + stmt = ( + select(ConsolidationMember, LegalEntity) + .join(LegalEntity, LegalEntity.id == ConsolidationMember.legal_entity_id) + .where(ConsolidationMember.consolidation_version_id == version_id) + .order_by(LegalEntity.name, LegalEntity.id) + ) + return [(row[0], row[1]) for row in await session.execute(stmt)] + + +async def bound_documents(session: AsyncSession, version_id: uuid.UUID) -> list[Document]: + """Документы, привязанные к версии (`consolidation_document`).""" + stmt = ( + select(Document) + .join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id) + .where(ConsolidationDocument.consolidation_version_id == version_id) + .order_by(Document.legal_entity_id, Document.osv_account_code, Document.period_quarter, Document.id) + ) + return list((await session.execute(stmt)).scalars()) + + +async def document_versions(session: AsyncSession, document_id: uuid.UUID) -> list[uuid.UUID]: + """Версии, к которым привязан документ (по `id`).""" + stmt = ( + select(ConsolidationDocument.consolidation_version_id) + .where(ConsolidationDocument.document_id == document_id) + .order_by(ConsolidationDocument.consolidation_version_id) + ) + return list((await session.execute(stmt)).scalars()) + + +async def load_osv_rows(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[OsvEntry, Document, LegalEntity]]: + """Исходные строки свода: контрагентские (`group_type = COUNTERPARTY`) строки BU/SUM привязанных документов. + + `TOTAL` не хранится, строки `ACCOUNT` и данные `NU` отбрасываются. Порядок: имя ЮЛ, код счёта документа, квартал, + `row_number` (а затем `OsvEntry.id` для устойчивости). + """ + stmt = ( + select(OsvEntry, Document, LegalEntity) + .join(Document, Document.id == OsvEntry.document_id) + .join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id) + .join(LegalEntity, LegalEntity.id == Document.legal_entity_id) + .where( + ConsolidationDocument.consolidation_version_id == version_id, + OsvEntry.group_type == OsvGroupType.COUNTERPARTY, + OsvEntry.data_type.in_(SUMMARY_DATA_TYPES), + ) + .order_by( + LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number, OsvEntry.id + ) + ) + return [(row[0], row[1], row[2]) for row in await session.execute(stmt)] + + +async def history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]: + """Журнал смен статуса версии по времени (`changed_at`, затем `id`).""" + stmt = ( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version_id) + .order_by(ConsolidationStatusHistory.changed_at, ConsolidationStatusHistory.id) + ) + return list((await session.execute(stmt)).scalars()) + + +async def entity_files_state( + session: AsyncSession, entity_ids: Sequence[uuid.UUID] +) -> dict[uuid.UUID, EntityFilesState]: + """Состояние файлов каждого ЮЛ из `entity_ids` (чек-лист, D-13): файл в работе и статус последнего файла. + + «В работе» — любой файл `UPLOADED`/`PROCESSING`; «последний» — по `uploaded_at` (при равенстве или NULL — по `id`), + включая замещённые файлы. ЮЛ без файлов — `EntityFilesState(False, None)`. Неподтверждённые документы-кандидаты + сюда не входят (R-5). + """ + states = {entity_id: EntityFilesState(has_in_progress=False, last_file_status=None) for entity_id in entity_ids} + if not states: + return states + in_progress = select(StoredFile.legal_entity_id).where( + StoredFile.legal_entity_id.in_(states), StoredFile.status.in_(FILES_IN_PROGRESS) + ) + busy = set((await session.execute(in_progress.distinct())).scalars()) + last = ( + select(StoredFile.legal_entity_id, StoredFile.status) + .where(StoredFile.legal_entity_id.in_(states)) + .distinct(StoredFile.legal_entity_id) + .order_by(StoredFile.legal_entity_id, StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id.desc()) + ) + last_status = {entity_id: status for entity_id, status in await session.execute(last)} + return { + entity_id: EntityFilesState(has_in_progress=entity_id in busy, last_file_status=last_status.get(entity_id)) + for entity_id in states + } diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py new file mode 100644 index 0000000..39109f0 --- /dev/null +++ b/api/src/schemas/consolidations.py @@ -0,0 +1,257 @@ +"""Схемы API консолидаций (Срез 4; spec consolidations-api, consolidated-osv; design D-16). + +Суммы — `Decimal` (в JSON — строка, без `float`). `ConsolidatedRowOut` и `OsvSummaryOut` — сводная ОСВ версии +(чистая функция, в БД не хранится; DECISION-5). `OsvFilters` — query-модель фильтров `GET …/osv` +(`Annotated[OsvFilters, Query()]`); `limit` ≤ 2000 по умолчанию 500, как у строк документа. +""" + +import datetime +import uuid +from decimal import Decimal +from typing import Literal + +from pydantic import BaseModel, Field + +from src.models.enums import ( + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + CounterpartyMatchKind, + DocumentType, + PeriodQuarter, +) + +YEAR_MIN = 2000 +YEAR_MAX = 2100 +DEFAULT_OSV_LIMIT = 500 +MAX_OSV_LIMIT = 2000 +# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний +CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] +# статус ячейки чек-листа (spec consolidations-api «Чек-лист комплекта») +ChecklistCellStatus = Literal["CONFIRMED", "NEEDS_CHECK", "RECOGNIZED", "PROCESSING", "ERROR", "MISSING"] + + +class RefOut(BaseModel): + """Ссылка на объект справочника: идентификатор и название (ГК, филиал, ГСК/ГСЗ).""" + + id: uuid.UUID + name: str + + +class ConsolidationCreate(BaseModel): + """Тело `POST /consolidations`: ГК, ГСК периметра, период.""" + + company_group_id: uuid.UUID + related_group_id: uuid.UUID + year: int = Field(ge=YEAR_MIN, le=YEAR_MAX) + quarter: PeriodQuarter + + +class LockOut(BaseModel): + """Активная блокировка версии: кто взял, когда, у кого перехвачена и по какой причине.""" + + locked_by: uuid.UUID + locked_by_name: str + locked_at: datetime.datetime + taken_over_from: uuid.UUID | None + takeover_reason: str | None + + +class LockIn(BaseModel): + """Тело `POST …/lock`: причина перехвата чужой блокировки (при взятии свободной версии не нужна).""" + + takeover_reason: str | None = None + + +class VersionBrief(BaseModel): + """Версия в списке периодов ГК.""" + + id: uuid.UUID + version_no: int + status: ConsolidationStatus + scenario: ConsolidationScenario + approach: ConsolidationApproach + lock: LockOut | None + created_at: datetime.datetime | None + members_count: int + + +class ConsolidationPeriodOut(BaseModel): + """Период ГК (консолидация) и её версии по убыванию `version_no`.""" + + consolidation_id: uuid.UUID + year: int + quarter: PeriodQuarter + reporting_date: datetime.date + versions: list[VersionBrief] + + +class ChecklistCellOut(BaseModel): + """Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" + + status: ChecklistCellStatus + missing_quarters: list[PeriodQuarter] + document_ids: list[uuid.UUID] + + +class MemberOut(BaseModel): + """ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа).""" + + id: uuid.UUID + name: str + inn: str + is_head_entity: bool + checklist: list[ChecklistCellOut] + + +class HistoryOut(BaseModel): + """Запись маршрута согласования (`consolidation_status_history`).""" + + from_status: ConsolidationStatus | None + to_status: ConsolidationStatus + changed_by: uuid.UUID + changed_at: datetime.datetime + comment: str | None + + +class VersionCard(VersionBrief): + """Карточка версии: версия + консолидация, ГК, инициатор, периметр с чек-листом и история.""" + + consolidation_id: uuid.UUID + year: int + quarter: PeriodQuarter + reporting_date: datetime.date + company_group: RefOut + initiator: RefOut + initiator_office: RefOut + goodwill_enabled: bool + is_in_work: bool + previous_version_id: uuid.UUID | None + members: list[MemberOut] + history: list[HistoryOut] + + +class MembersIn(BaseModel): + """Тело `PUT …/members`: новый периметр версии (дубли схлопываются сервисом).""" + + legal_entity_ids: list[uuid.UUID] + + +class StatusIn(BaseModel): + """Тело `POST …/status`: ручной переход (`CANCELLED` — комментарий обязателен; возврат в `INITIATED`).""" + + to_status: ConsolidationStatus + comment: str | None = None + + +class ChecklistRuleOut(BaseModel): + """Правило комплекта (`required_document_rule`): тип документа, счёт ОСВ, обязательность.""" + + document_type: DocumentType + account: str | None + is_mandatory: bool + + +class ChecklistRowOut(BaseModel): + """Строка чек-листа: ЮЛ периметра и ячейки в порядке `rules`.""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + legal_entity_inn: str + cells: list[ChecklistCellOut] + + +class ChecklistOut(BaseModel): + """Чек-лист комплекта документов версии: правила и строки по ЮЛ периметра.""" + + rules: list[ChecklistRuleOut] + rows: list[ChecklistRowOut] + + +class ConsolidatedRowOut(BaseModel): + """Строка сводной ОСВ версии (D-16): ЮЛ, счёт, период, контрагент (как в файле и после резолва), суммы.""" + + entry_ids: list[uuid.UUID] + legal_entity_id: uuid.UUID + legal_entity_name: str + legal_entity_inn: str + account_code: str | None + account_root: str | None + period: str # "<год> Q" + is_cumulative: bool + row_number: int | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + counterparty_entity_name: str | None + counterparty_type: CounterpartyType + match_kind: CounterpartyMatchKind | None + match_stored: bool # `False` — сопоставление найдено дорезолвом на чтении и в `osv_entry` не записано + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + warnings: list[str] + + +class GapOut(BaseModel): + """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся).""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + account_code: str + missing_quarters: list[PeriodQuarter] + + +class OsvSummaryOut(BaseModel): + """Сводка по своду версии (`GET …/osv/summary`).""" + + rows_total: int + unmapped_total: int + auto_matched_total: int + documents_total: int + gaps: list[GapOut] + + +class OsvFilters(BaseModel): + """Фильтры и пагинация `GET …/osv` из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый.""" + + legal_entity_id: uuid.UUID | None = None + account_root: str | None = None + counterparty_type: CounterpartyType | None = None + unmapped: bool | None = None + match_kind: list[CounterpartyMatchKind] | None = None + q: str | None = None + limit: int = Field(DEFAULT_OSV_LIMIT, ge=1, le=MAX_OSV_LIMIT) + offset: int = Field(0, ge=0) + + +class CandidateOut(BaseModel): + """Кандидат для ручного сопоставления контрагента (`GET …/counterparty-candidates`).""" + + id: uuid.UUID + inn: str + name: str + company_group: RefOut | None + gsk: RefOut | None + gsz: RefOut | None + counterparty_type: CounterpartyType + recommended: bool + + +class MapCounterpartyIn(BaseModel): + """Тело `POST …/osv/entries/{entry_id}/counterparty`: ЮЛ-контрагент и перенос чужого алиаса.""" + + legal_entity_id: uuid.UUID + move_alias: bool = False + + +class MapCounterpartyOut(BaseModel): + """Результат ручного сопоставления: обновлённая строка свода, число строк автоприменения, судьба алиаса.""" + + row: ConsolidatedRowOut + propagated: int + alias_created: bool + alias_moved_from: uuid.UUID | None diff --git a/api/tests/factories.py b/api/tests/factories.py index dfa1cae..99be452 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -3,6 +3,9 @@ - `make_regional_office`, `make_company_group`, `make_legal_entity`, `make_related_group`, `make_membership`, `make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте. +- `make_consolidation`, `make_version`, `make_lock`, `make_confirmed_osv` — консолидация ГК с версией №1, периметром и + историей; ещё одна версия; блокировка версии; подтверждённая ОСВ (файл `PROCESSED` + задание `done` + документ + `CONFIRMED` + строки контрагентов) для тестов Среза 4 (`flush`, без `commit`). - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. - `make_stored_file`, `make_document`, `make_result_object` — цепочка файла/задания/документа и объект результата в `FakeStorage` для тестов загрузки и маппинга (Срез 2c; `flush`, без `commit`). `make_entries_from_result` — @@ -28,6 +31,14 @@ from src.contract.result import OsvDocument, RecognitionResult from src.domain.osv_mapping import build_entries, document_fields from src.models.access import AppUser, Role, UserRole from src.models.accounting import ChartOfAccount +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) from src.models.directories import ( CompanyGroup, LegalEntity, @@ -36,12 +47,17 @@ from src.models.directories import ( RelatedGroupMember, ) from src.models.enums import ( + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, DocumentCheckResult, DocumentStatus, DocumentType, EntityType, FileStatus, FileType, + OsvDataType, + OsvGroupType, PeriodQuarter, RecognitionPipeline, RecognitionStatus, @@ -56,6 +72,22 @@ from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) FACTORY_BUCKET = "arm-ka" +# первый день месяца, следующего за кварталом (reporting_date, ASSUMPTION(OQ-40)); Q4 — 1 января следующего года +REPORTING_DATES = { + PeriodQuarter.Q1: (4, 0), + PeriodQuarter.Q2: (7, 0), + PeriodQuarter.Q3: (10, 0), + PeriodQuarter.Q4: (1, 1), +} +# статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию +FINISHED_STATUSES = frozenset( + { + ConsolidationStatus.VERIFIED, + ConsolidationStatus.CANCELLED, + ConsolidationStatus.ARCHIVED, + ConsolidationStatus.REJECTED, + } +) def _now() -> datetime.datetime: @@ -345,6 +377,158 @@ async def make_entries_from_result( return documents +async def make_version( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + consolidation: Consolidation, + version_no: int, + members: Sequence[LegalEntity], + initiator: AppUser, + status: ConsolidationStatus = ConsolidationStatus.INITIATED, +) -> ConsolidationVersion: + """Версия `version_no` консолидации: `consolidation_member` на каждое ЮЛ и история (NULL → INITIATED[→ status]). + + `is_in_work = false` для VERIFIED / CANCELLED / ARCHIVED / REJECTED. История: если `status` не `INITIATED`, второй + записью — переход `INITIATED → status` (одним пользователем). Данные остаются в транзакции сессии (`flush`). + """ + now = _now() + version = ConsolidationVersion( + consolidation_id=consolidation.id, + version_no=version_no, + status=status, + scenario=ConsolidationScenario.CREATE, + approach=ConsolidationApproach.BASIC, + goodwill_enabled=False, + initiator_id=initiator.id, + initiator_office_id=initiator.regional_office_id, + is_in_work=status not in FINISHED_STATUSES, + created_at=now, + created_by=initiator.id, + ) + session.add(version) + await session.flush() + session.add_all( + ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False) + for entity in members + ) + history = [(None, ConsolidationStatus.INITIATED)] + if status != ConsolidationStatus.INITIATED: + history.append((ConsolidationStatus.INITIATED, status)) + session.add_all( + ConsolidationStatusHistory( + version_id=version.id, + from_status=old, + to_status=new, + changed_by=initiator.id, + changed_at=now + datetime.timedelta(milliseconds=position), # порядок записей детерминирован + ) + for position, (old, new) in enumerate(history) + ) + await session.flush() + return version + + +async def make_consolidation( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + group: CompanyGroup, + year: int, + quarter: PeriodQuarter, + members: Sequence[LegalEntity], + initiator: AppUser, + status: ConsolidationStatus = ConsolidationStatus.INITIATED, +) -> tuple[Consolidation, ConsolidationVersion]: + """Консолидация ГК за период и её версия №1 (периметр `members`, история первой записью); `flush`. + + `reporting_date` — по правилу спеки: Q1 → 01.04, Q2 → 01.07, Q3 → 01.10, Q4 → 01.01 следующего года. + """ + month, year_shift = REPORTING_DATES[quarter] + consolidation = Consolidation( + company_group_id=group.id, + year=year, + quarter=quarter, + reporting_date=datetime.date(year + year_shift, month, 1), + created_at=_now(), + ) + session.add(consolidation) + await session.flush() + version = await make_version( + session, consolidation=consolidation, version_no=1, members=members, initiator=initiator, status=status + ) + return consolidation, version + + +async def make_lock( + session: AsyncSession, *, version: ConsolidationVersion, user: AppUser, released: bool = False +) -> ConsolidationLock: + """Блокировка версии пользователем `user`; `released=True` — уже снята (`released_at` задан); `flush`.""" + now = _now() + lock = ConsolidationLock( + consolidation_version_id=version.id, + locked_by=user.id, + locked_at=now, + released_at=now if released else None, + ) + session.add(lock) + await session.flush() + return lock + + +async def make_confirmed_osv( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + entity: LegalEntity, + account: str, + year: int, + quarter: PeriodQuarter, + is_cumulative: bool, + rows: Sequence[dict[str, Any]], + confirmed_by: uuid.UUID | None = None, +) -> Document: + """Подтверждённая ОСВ ЮЛ: файл `PROCESSED` + задание `done` + документ `CONFIRMED` + строки; `flush`. + + Каждая строка `rows` — словарь: `name` → `counterparty_name`, `inn` → `counterparty_inn`, остальные ключи — поля + `OsvEntry` как есть (`opening_debit`, `closing_credit`, … и при необходимости `group_type` / `data_type`, по + умолчанию `COUNTERPARTY` / `BU`). `row_number` — по порядку с 1; `account_code` / `account_root` — из `account`. + """ + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document( + session, + file=file, + job=job, + account=account, + year=year, + quarter=quarter, + status=DocumentStatus.CONFIRMED, + is_cumulative=is_cumulative, + ) + document.confirmed_by = confirmed_by + document.confirmed_at = _now() + entries: list[OsvEntry] = [] + for number, row in enumerate(rows, start=1): + fields = dict(row) + fields.setdefault("group_type", OsvGroupType.COUNTERPARTY) + fields.setdefault("data_type", OsvDataType.BU) + fields["counterparty_name"] = fields.pop("name", None) + fields["counterparty_inn"] = fields.pop("inn", None) + entries.append( + OsvEntry( + recognition_job_id=job.id, + document_id=document.id, + owner_legal_entity_id=entity.id, + row_number=number, + account_code=account, + account_root=account.split(".", maxsplit=1)[0], + **fields, + ) + ) + session.add_all(entries) + await session.flush() + return document + + async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. @@ -579,10 +763,61 @@ class CommittedJobs: """Запомнить строку `s3_object`, созданную тестом вне фабрики (удаляется в `cleanup()`).""" self._extra_s3_ids.append(s3_object_id) + async def _delete_consolidations(self, session: AsyncSession) -> None: + """Привязки, блокировки, история, периметры, версии и консолидации, ссылающиеся на созданные ЮЛ и документы. + + Версии — те, где созданное ЮЛ в периметре или привязан документ созданного файла; консолидации — только те, + у которых после этого не осталось версий. Выполняется до удаления документов и ЮЛ (FK на них). + """ + documents = select(Document.id).where(Document.stored_file_id.in_(self._file_ids)) + by_document = select(ConsolidationDocument.consolidation_version_id).where( + ConsolidationDocument.document_id.in_(documents) + ) + by_member = select(ConsolidationMember.consolidation_version_id).where( + ConsolidationMember.legal_entity_id.in_(self._entity_ids) + ) + version_ids = [ + row[0] + for row in await session.execute( + select(ConsolidationVersion.id).where(ConsolidationVersion.id.in_(by_document.union(by_member))) + ) + ] + if not version_ids: + return + consolidation_ids = select(ConsolidationVersion.consolidation_id).where( + ConsolidationVersion.id.in_(version_ids) + ) + touched = [row[0] for row in await session.execute(consolidation_ids)] + await session.execute( + update(ConsolidationVersion) + .where(ConsolidationVersion.previous_version_id.in_(version_ids)) + .values(previous_version_id=None) + ) + await session.execute( + delete(ConsolidationDocument).where(ConsolidationDocument.consolidation_version_id.in_(version_ids)) + ) + await session.execute( + delete(ConsolidationLock).where(ConsolidationLock.consolidation_version_id.in_(version_ids)) + ) + await session.execute( + delete(ConsolidationStatusHistory).where(ConsolidationStatusHistory.version_id.in_(version_ids)) + ) + await session.execute( + delete(ConsolidationMember).where(ConsolidationMember.consolidation_version_id.in_(version_ids)) + ) + await session.execute(delete(ConsolidationVersion).where(ConsolidationVersion.id.in_(version_ids))) + remaining = select(ConsolidationVersion.consolidation_id).where( + ConsolidationVersion.consolidation_id.in_(touched) + ) + await session.execute( + delete(Consolidation).where(Consolidation.id.in_(touched), Consolidation.id.not_in(remaining)) + ) + async def cleanup(self) -> None: """Удаляет всё созданное в порядке FK: строки ОСВ и документы файлов → задания → файлы → объекты S3 и ЮЛ.""" async with self._session_factory() as session, session.begin(): result_s3_ids: list[uuid.UUID] = [] + await self._delete_consolidations(session) if self._file_ids: # задания ссылаются на файл, файл — на текущее задание (use_alter): сначала разрываем обратную ссылку await session.execute( diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py index bcfeca9..c840027 100644 --- a/api/tests/test_factories.py +++ b/api/tests/test_factories.py @@ -1,5 +1,6 @@ """Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов.""" +import datetime import json from decimal import Decimal from pathlib import Path @@ -11,12 +12,24 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.core.storage import ObjectNotFoundError from src.models.access import UserRole +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import LegalEntity from src.models.enums import ( + ConsolidationStatus, DocumentStatus, DocumentType, EntityType, FileStatus, FileType, + OsvDataType, + OsvGroupType, PeriodQuarter, RecognitionPipeline, RecognitionStatus, @@ -29,9 +42,12 @@ from tests.conftest import AuthClientFactory, FakeStorage from tests.factories import ( CommittedJobs, make_company_group, + make_confirmed_osv, + make_consolidation, make_document, make_entries_from_result, make_legal_entity, + make_lock, make_membership, make_regional_office, make_related_group, @@ -300,3 +316,168 @@ async def test_committed_jobs_cleanup_removes_documents_and_entries( assert await session.get(StoredFile, file_id) is None assert not (await session.execute(select(Document).where(Document.stored_file_id == file_id))).all() assert not (await session.execute(select(OsvEntry).where(OsvEntry.recognition_job_id == job_id))).all() + + +@pytest.mark.integration +async def test_make_consolidation_builds_version_one_with_members_and_history(db_session: AsyncSession) -> None: + office = await make_regional_office(db_session) + group = await make_company_group(db_session, office=office) + entities = [await make_legal_entity(db_session, group=group) for _ in range(2)] + user = await make_user(db_session, email="cons@armka.local", office=office) + + consolidation, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + + assert consolidation.company_group_id == group.id + assert (consolidation.year, consolidation.quarter) == (2024, PeriodQuarter.Q3) + assert consolidation.reporting_date == datetime.date(2024, 10, 1) + assert (version.consolidation_id, version.version_no, version.status) == ( + consolidation.id, + 1, + ConsolidationStatus.INITIATED, + ) + assert (version.initiator_id, version.initiator_office_id, version.is_in_work) == (user.id, office.id, True) + members = ( + ( + await db_session.execute( + select(ConsolidationMember).where(ConsolidationMember.consolidation_version_id == version.id) + ) + ) + .scalars() + .all() + ) + assert {member.legal_entity_id for member in members} == {entity.id for entity in entities} + history = ( + ( + await db_session.execute( + select(ConsolidationStatusHistory).where(ConsolidationStatusHistory.version_id == version.id) + ) + ) + .scalars() + .all() + ) + assert [(row.from_status, row.to_status, row.changed_by) for row in history] == [ + (None, ConsolidationStatus.INITIATED, user.id) + ] + + +@pytest.mark.integration +async def test_make_consolidation_other_status_and_lock(db_session: AsyncSession) -> None: + office = await make_regional_office(db_session) + group = await make_company_group(db_session, office=office) + entity = await make_legal_entity(db_session, group=group) + user = await make_user(db_session, email="cons2@armka.local", office=office) + + _, version = await make_consolidation( + db_session, + group=group, + year=2024, + quarter=PeriodQuarter.Q4, + members=[entity], + initiator=user, + status=ConsolidationStatus.CANCELLED, + ) + active = await make_lock(db_session, version=version, user=user) + released = await make_lock(db_session, version=version, user=user, released=True) + + assert version.status == ConsolidationStatus.CANCELLED + assert version.is_in_work is False + assert (await db_session.get(ConsolidationLock, active.id)) is active + assert (active.locked_by, active.released_at) == (user.id, None) + assert released.released_at is not None + + +@pytest.mark.integration +async def test_make_confirmed_osv_builds_file_job_document_and_rows(db_session: AsyncSession) -> None: + entity = await make_legal_entity(db_session) + user = await make_user(db_session, email="conf@armka.local", office=await make_regional_office(db_session)) + + document = await make_confirmed_osv( + db_session, + entity=entity, + account="60", + year=2024, + quarter=PeriodQuarter.Q2, + is_cumulative=False, + rows=[ + {"name": "Контрагент А", "inn": "7701000001", "opening_debit": Decimal("10.50")}, + {"name": "Контрагент Б", "closing_credit": Decimal("3.00"), "data_type": OsvDataType.SUM}, + ], + confirmed_by=user.id, + ) + + assert (document.legal_entity_id, document.document_type, document.osv_account_code) == ( + entity.id, + DocumentType.OSV, + "60", + ) + assert (document.period_year, document.period_quarter, document.is_cumulative) == (2024, PeriodQuarter.Q2, False) + assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, user.id) + assert document.confirmed_at is not None + file = await db_session.get(StoredFile, document.stored_file_id) + assert file is not None + assert file.status == FileStatus.PROCESSED + job = await db_session.get(RecognitionJob, file.current_recognition_job_id) + assert job is not None + assert job.status == RecognitionStatus.done + rows = ( + ( + await db_session.execute( + select(OsvEntry).where(OsvEntry.document_id == document.id).order_by(OsvEntry.row_number) + ) + ) + .scalars() + .all() + ) + assert [(row.row_number, row.counterparty_name, row.counterparty_inn) for row in rows] == [ + (1, "Контрагент А", "7701000001"), + (2, "Контрагент Б", None), + ] + assert [(row.group_type, row.data_type) for row in rows] == [ + (OsvGroupType.COUNTERPARTY, OsvDataType.BU), + (OsvGroupType.COUNTERPARTY, OsvDataType.SUM), + ] + assert (rows[0].opening_debit, rows[1].closing_credit) == (Decimal("10.50"), Decimal("3.00")) + assert all(row.owner_legal_entity_id == entity.id and row.recognition_job_id == job.id for row in rows) + assert (rows[0].account_code, rows[0].account_root) == ("60", "60") + + +@pytest.mark.integration +async def test_committed_jobs_cleanup_removes_consolidation_rows( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """`cleanup()` снимает привязки, блокировки, историю, периметр, версии и консолидацию до файлов и ЮЛ.""" + jobs = CommittedJobs(committed_sessionmaker) + file_id, job_id = await jobs.make_file_job() + try: + async with committed_sessionmaker() as session, session.begin(): + file = await session.get(StoredFile, file_id) + job = await session.get(RecognitionJob, job_id) + assert file is not None + assert job is not None + entity = await session.get(LegalEntity, file.legal_entity_id) + assert entity is not None + office = await make_regional_office(session) + group = await make_company_group(session, office=office) + user = await make_user(session, email="cleanup@armka.local", office=office) + document = await make_document(session, file=file, job=job) + _, version = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=[entity], initiator=user + ) + await make_lock(session, version=version, user=user) + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + finally: + await jobs.cleanup() + + async with committed_sessionmaker() as session: + assert await session.get(StoredFile, file_id) is None + for model in ( + ConsolidationDocument, + ConsolidationLock, + ConsolidationStatusHistory, + ConsolidationMember, + ConsolidationVersion, + Consolidation, + ): + assert not (await session.execute(select(model))).all(), model.__name__ diff --git a/api/tests/test_repositories_consolidations.py b/api/tests/test_repositories_consolidations.py new file mode 100644 index 0000000..84e1308 --- /dev/null +++ b/api/tests/test_repositories_consolidations.py @@ -0,0 +1,401 @@ +"""Репозиторий консолидаций (Срез 4, задача 1.2): выборки версий, блокировки, строки свода, состояние файлов ЮЛ.""" + +import asyncio +import datetime +import uuid +from decimal import Decimal + +import pytest +from sqlalchemy import select, text, update +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.domain.checklist import EntityFilesState +from src.domain.policy import VisibilityScope +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.repositories import consolidations as repo +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_legal_entity, + make_lock, + make_regional_office, + make_stored_file, + make_user, +) + +GLOBAL = VisibilityScope(office_id=None) +IN_WORK = ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, +) +LOCK_NOT_AVAILABLE_TIMEOUT = 5 + + +async def _world( + session: AsyncSession, *, members: int = 2 +) -> tuple[RegionalOffice, CompanyGroup, list[LegalEntity], AppUser]: + """Филиал, ГК с `members` ЮЛ (имена «ЮЛ-0», «ЮЛ-1», …) и пользователь-инициатор.""" + office = await make_regional_office(session) + group = await make_company_group(session, office=office) + entities = [await make_legal_entity(session, name=f"ЮЛ-{i}", group=group) for i in range(members)] + user = await make_user(session, email=f"u{uuid.uuid4().hex[:8]}@armka.local", office=office) + return office, group, entities, user + + +@pytest.mark.integration +async def test_list_periods_sorts_periods_and_versions_descending(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session) + q1, first = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=user + ) + q3, _ = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + second = ConsolidationVersion( + consolidation_id=q1.id, + version_no=2, + status=ConsolidationStatus.INITIATED, + scenario=first.scenario, + initiator_id=user.id, + initiator_office_id=user.regional_office_id, + ) + db_session.add(second) + await db_session.flush() + + periods = await repo.list_periods(db_session, GLOBAL, group.id) + + assert [(c.year, c.quarter) for c, _ in periods] == [(2024, PeriodQuarter.Q3), (2024, PeriodQuarter.Q1)] + assert [[v.version_no for v in versions] for _, versions in periods] == [[1], [2, 1]] + assert periods[0][0].id == q3.id + + +@pytest.mark.integration +async def test_list_periods_hides_foreign_office_group(db_session: AsyncSession) -> None: + office, group, entities, user = await _world(db_session) + other = await make_regional_office(db_session) + await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=user + ) + + assert len(await repo.list_periods(db_session, VisibilityScope(office_id=office.id), group.id)) == 1 + assert await repo.list_periods(db_session, VisibilityScope(office_id=other.id), group.id) == [] + + +@pytest.mark.integration +async def test_get_version_respects_visibility(db_session: AsyncSession) -> None: + office, group, entities, user = await _world(db_session) + other = await make_regional_office(db_session) + consolidation, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + + found = await repo.get_version(db_session, VisibilityScope(office_id=office.id), version.id) + assert found is not None + assert (found[0].id, found[1].id) == (version.id, consolidation.id) + assert await repo.get_version(db_session, GLOBAL, version.id) is not None + assert await repo.get_version(db_session, VisibilityScope(office_id=other.id), version.id) is None # чужой филиал + assert await repo.get_version(db_session, GLOBAL, uuid.uuid4()) is None + + +@pytest.mark.integration +async def test_get_consolidation_for_group_by_period(db_session: AsyncSession) -> None: + office, group, entities, user = await _world(db_session) + other = await make_regional_office(db_session) + consolidation, _ = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + own = VisibilityScope(office_id=office.id) + + found = await repo.get_consolidation_for_group(db_session, own, group.id, 2024, PeriodQuarter.Q3) + assert found is not None + assert found.id == consolidation.id + assert await repo.get_consolidation_for_group(db_session, own, group.id, 2024, PeriodQuarter.Q2) is None + assert ( + await repo.get_consolidation_for_group( + db_session, VisibilityScope(office_id=other.id), group.id, 2024, PeriodQuarter.Q3 + ) + is None + ) + + +@pytest.mark.integration +async def test_lock_version_returns_version_or_none(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session) + _, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + # статус меняется в БД в обход ORM: объект в сессии остаётся прежним, `populate_existing` перечитывает строку + await db_session.execute( + update(ConsolidationVersion) + .where(ConsolidationVersion.id == version.id) + .values(status=ConsolidationStatus.CANCELLED) + .execution_options(synchronize_session=False) + ) + assert version.status == ConsolidationStatus.INITIATED + + locked = await repo.lock_version(db_session, version.id) + + assert locked is not None + assert locked is version + assert locked.status == ConsolidationStatus.CANCELLED + assert await repo.lock_version(db_session, uuid.uuid4()) is None + + +@pytest.mark.integration +async def test_lock_versions_for_entity_returns_only_in_work_member_versions_by_id(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session, members=3) + first, second, outsider = entities + created: dict[ConsolidationStatus, ConsolidationVersion] = {} + for quarter, status in zip( + (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4), + ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DATA_CORRECTION, + ConsolidationStatus.VERIFIED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ), + strict=True, + ): + _, created[status] = await make_consolidation( + db_session, group=group, year=2024, quarter=quarter, members=[first, second], initiator=user, status=status + ) + # ещё одна версия «в работе», в периметре которой ЮЛ нет + await make_consolidation( + db_session, group=group, year=2023, quarter=PeriodQuarter.Q1, members=[second, outsider], initiator=user + ) + + versions = await repo.lock_versions_for_entity(db_session, first.id, IN_WORK) + + expected = sorted( + created[status].id + for status in ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DATA_CORRECTION, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ) + ) + assert [version.id for version in versions] == expected + + +@pytest.mark.integration +async def test_lock_versions_for_entity_locks_superset_but_returns_members_only( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-2: версия ГК «в работе», где ЮЛ не член, блокируется (правка периметра ждёт), но не возвращается.""" + async with committed_sessionmaker() as setup, setup.begin(): + _, group, entities, user = await _world(setup, members=3) + member_entity, other_entity, third = entities + _, member_version = await make_consolidation( + setup, + group=group, + year=2024, + quarter=PeriodQuarter.Q1, + members=[member_entity, other_entity], + initiator=user, + ) + _, superset_version = await make_consolidation( + setup, group=group, year=2024, quarter=PeriodQuarter.Q2, members=[other_entity, third], initiator=user + ) + foreign_group = await make_company_group(setup) + _, foreign_version = await make_consolidation( + setup, + group=foreign_group, + year=2024, + quarter=PeriodQuarter.Q1, + members=[await make_legal_entity(setup, group=foreign_group)] * 1, + initiator=user, + ) + ids = (member_entity.id, member_version.id, superset_version.id, foreign_version.id) + entity_id, member_id, superset_id, foreign_id = ids + + async def try_lock(session: AsyncSession, version_id: uuid.UUID) -> bool: + """`True` — строка заблокирована другой транзакцией (NOWAIT не получил блокировку).""" + try: + async with session.begin_nested(): + await session.execute( + text("SELECT id FROM consolidation_version WHERE id = :id FOR UPDATE NOWAIT"), {"id": version_id} + ) + except DBAPIError: + return True + return False + + async with committed_sessionmaker() as locker, locker.begin(), committed_sessionmaker() as probe, probe.begin(): + returned = await asyncio.wait_for( + repo.lock_versions_for_entity(locker, entity_id, IN_WORK), timeout=LOCK_NOT_AVAILABLE_TIMEOUT + ) + assert [version.id for version in returned] == [member_id] + assert await try_lock(probe, member_id) is True + assert await try_lock(probe, superset_id) is True # надмножество: ЮЛ не член, но версия той же ГК + assert await try_lock(probe, foreign_id) is False # версия чужой ГК не блокируется + + +@pytest.mark.integration +async def test_active_lock_ignores_released(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session) + _, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + assert await repo.active_lock(db_session, version.id) is None + await make_lock(db_session, version=version, user=user, released=True) + assert await repo.active_lock(db_session, version.id) is None + active = await make_lock(db_session, version=version, user=user) + + found = await repo.active_lock(db_session, version.id) + + assert found is not None + assert found.id == active.id + + +@pytest.mark.integration +async def test_members_sorted_by_entity_name(db_session: AsyncSession) -> None: + office = await make_regional_office(db_session) + group = await make_company_group(db_session, office=office) + user = await make_user(db_session, email="m@armka.local", office=office) + entities = [await make_legal_entity(db_session, name=name, group=group) for name in ("Яблоко", "Арбуз", "Мандарин")] + _, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + + rows = await repo.members(db_session, version.id) + + assert [entity.name for _, entity in rows] == ["Арбуз", "Мандарин", "Яблоко"] + assert all(member.consolidation_version_id == version.id for member, _ in rows) + + +@pytest.mark.integration +async def test_history_in_chronological_order(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session) + _, version = await make_consolidation( + db_session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=entities, + initiator=user, + status=ConsolidationStatus.DATA_CORRECTION, + ) + + history = await repo.history(db_session, version.id) + + assert [(row.from_status, row.to_status) for row in history] == [ + (None, ConsolidationStatus.INITIATED), + (ConsolidationStatus.INITIATED, ConsolidationStatus.DATA_CORRECTION), + ] + + +async def _osv_rows() -> list[dict[str, object]]: + return [ + {"name": "Контрагент А", "inn": "7701000001", "opening_debit": Decimal("100.00")}, + {"name": "Контрагент Б", "inn": None, "closing_credit": Decimal("5.50"), "data_type": OsvDataType.SUM}, + {"name": "Итого", "group_type": OsvGroupType.ACCOUNT}, + {"name": "Контрагент Н", "data_type": OsvDataType.NU}, + {"name": "Договор", "group_type": OsvGroupType.CONTRACT}, + ] + + +@pytest.mark.integration +async def test_load_osv_rows_keeps_counterparty_bu_sum_only_in_order(db_session: AsyncSession) -> None: + office = await make_regional_office(db_session) + group = await make_company_group(db_session, office=office) + user = await make_user(db_session, email="o@armka.local", office=office) + late = await make_legal_entity(db_session, name="Ящик", group=group) + early = await make_legal_entity(db_session, name="Арбуз", group=group) + _, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=[late, early], initiator=user + ) + rows = await _osv_rows() + docs = [ + await make_confirmed_osv( + db_session, entity=entity, account=account, year=2024, quarter=quarter, is_cumulative=False, rows=rows + ) + for entity, account, quarter in ( + (late, "60", PeriodQuarter.Q1), + (early, "62", PeriodQuarter.Q1), + (early, "60", PeriodQuarter.Q3), + (early, "60", PeriodQuarter.Q1), + ) + ] + await make_confirmed_osv( # не привязан к версии: в свод не попадает + db_session, entity=early, account="60", year=2024, quarter=PeriodQuarter.Q2, is_cumulative=False, rows=rows + ) + for document in docs: + db_session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await db_session.flush() + + loaded = await repo.load_osv_rows(db_session, version.id) + + # отброшены ACCOUNT, CONTRACT, NU; остались «А» (BU) и «Б» (SUM) каждого документа + assert {entry.counterparty_name for entry, _, _ in loaded} == {"Контрагент А", "Контрагент Б"} + assert len(loaded) == 2 * len(docs) + keys = [(entity.name, doc.osv_account_code, doc.period_quarter, entry.row_number) for entry, doc, entity in loaded] + assert keys == sorted(keys, key=lambda k: (k[0], k[1], k[2].value, k[3])) + assert keys[0] == ("Арбуз", "60", PeriodQuarter.Q1, 1) + assert [k[0] for k in keys].count("Ящик") == 2 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_bound_documents_and_document_versions(db_session: AsyncSession) -> None: + _, group, entities, user = await _world(db_session) + _, first = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + _, second = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q2, members=entities, initiator=user + ) + document = await make_confirmed_osv( + db_session, entity=entities[0], account="60", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=False, rows=[] + ) + unbound = await make_confirmed_osv( + db_session, entity=entities[1], account="60", year=2024, quarter=PeriodQuarter.Q1, is_cumulative=False, rows=[] + ) + for version in (first, second): + db_session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await db_session.flush() + + assert [d.id for d in await repo.bound_documents(db_session, first.id)] == [document.id] + assert sorted(await repo.document_versions(db_session, document.id)) == sorted([first.id, second.id]) + assert await repo.document_versions(db_session, unbound.id) == [] + + +@pytest.mark.integration +async def test_entity_files_state_in_progress_and_last_status(db_session: AsyncSession) -> None: + group = await make_company_group(db_session) + busy = await make_legal_entity(db_session, group=group) + failed = await make_legal_entity(db_session, group=group) + idle = await make_legal_entity(db_session, group=group) + recovered = await make_legal_entity(db_session, group=group) + await make_stored_file(db_session, entity=busy, status=FileStatus.UPLOADED) + await make_stored_file(db_session, entity=busy, status=FileStatus.PROCESSING) + old, _ = await make_stored_file(db_session, entity=failed, status=FileStatus.PROCESSED) + newer, _ = await make_stored_file(db_session, entity=failed, status=FileStatus.ERROR) + old.uploaded_at = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) + newer.uploaded_at = datetime.datetime(2024, 2, 1, tzinfo=datetime.UTC) + bad, _ = await make_stored_file(db_session, entity=recovered, status=FileStatus.ERROR) + good, _ = await make_stored_file(db_session, entity=recovered, status=FileStatus.PROCESSED) + bad.uploaded_at = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) + good.uploaded_at = datetime.datetime(2024, 2, 1, tzinfo=datetime.UTC) + await db_session.flush() + + state = await repo.entity_files_state(db_session, [busy.id, failed.id, idle.id, recovered.id]) + + assert state[busy.id].has_in_progress is True + assert state[failed.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.ERROR) + assert state[idle.id] == EntityFilesState(has_in_progress=False, last_file_status=None) + assert state[recovered.id] == EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED) + assert await repo.entity_files_state(db_session, []) == {} + + +@pytest.mark.integration +async def test_repository_returns_selectable_models(db_session: AsyncSession) -> None: + """Дымовая проверка: список версий ЮЛ после `make_consolidation` читается обычным запросом.""" + _, group, entities, user = await _world(db_session) + _, version = await make_consolidation( + db_session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=user + ) + assert (await db_session.execute(select(ConsolidationVersion.id))).scalars().all().count(version.id) == 1 diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py new file mode 100644 index 0000000..8006eb5 --- /dev/null +++ b/api/tests/test_schemas_consolidations.py @@ -0,0 +1,81 @@ +"""Схемы консолидаций (Срез 4, D-16): границы валидации, суммы-строки в JSON, неизменяемость доменного состояния.""" + +import dataclasses +import uuid +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from src.domain.checklist import EntityFilesState +from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter +from src.schemas.consolidations import ( + ConsolidatedRowOut, + ConsolidationCreate, + MapCounterpartyIn, + OsvFilters, + StatusIn, +) + +MAX_LIMIT = 2000 + + +def test_consolidation_create_year_bounds() -> None: + ids = {"company_group_id": uuid.uuid4(), "related_group_id": uuid.uuid4(), "quarter": "Q3"} + assert ConsolidationCreate(year=2024, **ids).quarter == PeriodQuarter.Q3 + for year in (1999, 2101): + with pytest.raises(ValidationError): + ConsolidationCreate(year=year, **ids) + + +def test_osv_filters_limit_bounds_and_repeated_match_kind() -> None: + assert OsvFilters().limit == 500 # noqa: PLR2004 + assert OsvFilters(limit=MAX_LIMIT).limit == MAX_LIMIT + for bad in ({"limit": MAX_LIMIT + 1}, {"limit": 0}, {"offset": -1}, {"counterparty_type": "OTHER"}): + with pytest.raises(ValidationError): + OsvFilters(**bad) + assert OsvFilters(match_kind=["NAME", "ALIAS"]).match_kind == [ + CounterpartyMatchKind.NAME, + CounterpartyMatchKind.ALIAS, + ] + + +def test_defaults_of_input_schemas() -> None: + assert MapCounterpartyIn(legal_entity_id=uuid.uuid4()).move_alias is False + assert StatusIn(to_status="CANCELLED").comment is None + + +def test_consolidated_row_serializes_money_as_strings() -> None: + row = ConsolidatedRowOut( + entry_ids=[uuid.uuid4()], + legal_entity_id=uuid.uuid4(), + legal_entity_name="ООО", + legal_entity_inn="7701000001", + account_code="60", + account_root="60", + period="2024 Q3", + is_cumulative=False, + row_number=1, + counterparty_name="А", + counterparty_inn=None, + counterparty_id=None, + counterparty_entity_name=None, + counterparty_type="EXTERNAL", + match_kind=None, + match_stored=False, + opening_debit=Decimal("100.50"), + opening_credit=None, + turnover_debit=Decimal("0.00"), + turnover_credit=None, + closing_debit=None, + closing_credit=None, + warnings=[], + ) + dumped = row.model_dump(mode="json") + assert (dumped["opening_debit"], dumped["opening_credit"], dumped["turnover_debit"]) == ("100.50", None, "0.00") + + +def test_entity_files_state_is_frozen() -> None: + state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) + with pytest.raises(dataclasses.FrozenInstanceError): + state.has_in_progress = False # type: ignore[misc] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index b128fd3..e02ed81 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -66,9 +66,9 @@ - Репозиторий (все функции принимают `session`, без policy, кроме `get_version`): `get_version(session, scope, version_id) -> tuple[ConsolidationVersion, Consolidation] | None` (join `consolidation` → `company_group`, `company_groups_visible`); `get_consolidation_for_group(session, scope, group_id, year, quarter)`; `list_periods(session, scope, group_id) -> list[tuple[Consolidation, list[ConsolidationVersion]]]`; `lock_version(session, version_id) -> ConsolidationVersion | None` (`FOR UPDATE`, `populate_existing`); `lock_versions_for_entity(session, entity_id, statuses) -> list[ConsolidationVersion]` (версии «в работе» с ЮЛ в периметре, `ORDER BY id FOR UPDATE`); `active_lock(session, version_id) -> ConsolidationLock | None`; `members(session, version_id) -> list[tuple[ConsolidationMember, LegalEntity]]` (по имени ЮЛ); `bound_documents(session, version_id) -> list[Document]`; `document_versions(session, document_id) -> list[uuid.UUID]`; `load_osv_rows(session, version_id) -> list[tuple[OsvEntry, Document, LegalEntity]]` (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`, через `consolidation_document`, `ORDER BY LegalEntity.name, Document.osv_account_code, Document.period_quarter, OsvEntry.row_number`); `history(session, version_id) -> list[ConsolidationStatusHistory]`; `entity_files_state(session, entity_ids) -> dict[uuid.UUID, EntityFilesState]` (`has_in_progress`, `last_file_status`, неподтверждённые документы по (тип, счёт, год, квартал, is_cumulative, status)). - Схемы (D-16): `ConsolidationCreate`, `LockOut`, `VersionBrief`, `ConsolidationPeriodOut`, `MemberOut` (+ `checklist: list[ChecklistCellOut]`), `HistoryOut`, `VersionCard`, `MembersIn`, `LockIn {takeover_reason: str | None}`, `StatusIn {to_status, comment}`, `ChecklistOut {rules[], rows[]}`, `ChecklistCellOut {status, missing_quarters, document_ids}`, `ConsolidatedRowOut`, `OsvSummaryOut`, `GapOut`, `OsvFilters` (query-модель), `CandidateOut`, `MapCounterpartyIn {legal_entity_id, move_alias: bool = False}`, `MapCounterpartyOut {row, propagated, alias_created, alias_moved_from}`. Суммы — `Decimal | None`. -- [ ] **Step 1: Тесты.** `test_repositories_consolidations.py` (integration): `list_periods` сортирует 2024 Q3 перед 2024 Q1 и версии по убыванию; `get_version` для ГК чужого филиала → None; `lock_versions_for_entity` отдаёт только «в работе» версии с ЮЛ в периметре по `id`; `load_osv_rows` отбрасывает `TOTAL`/`ACCOUNT`/`NU`; `test_factories`: новые фабрики создают связные строки. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_repositories_consolidations.py tests/test_factories.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): репозиторий, схемы и фабрики консолидаций`. +- [x] **Step 1: Тесты.** `test_repositories_consolidations.py` (integration): `list_periods` сортирует 2024 Q3 перед 2024 Q1 и версии по убыванию; `get_version` для ГК чужого филиала → None; `lock_versions_for_entity` отдаёт только «в работе» версии с ЮЛ в периметре по `id`; `load_osv_rows` отбрасывает `TOTAL`/`ACCOUNT`/`NU`; `test_factories`: новые фабрики создают связные строки. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_repositories_consolidations.py tests/test_factories.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): репозиторий, схемы и фабрики консолидаций`. --- diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 0b7217b..1f4b71d 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -11,7 +11,7 @@ `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)`, +- [x] 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.47.2 From 9411d9d2ae4f469aaec977bde0aa41025c43b210 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:57:52 +0400 Subject: [PATCH 06/27] =?UTF-8?q?feat(consolidation):=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D0=93=D0=9A,=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /consolidations (версия №1 INITIATED, периметр из текущих членов ГСК, история, CONSOLIDATION_CREATE с consolidation_version_id), GET /company-groups/{id}/consolidations и GET /consolidations/versions/{id}. reporting_date по OQ-40; подхват документов — заглушка bind_confirmed до Task 6, чек-лист в карточке пуст до Task 8. UNIQUE периода -> 409 CONSOLIDATION_EXISTS; RequestAudit.record принимает consolidation_version_id. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/audit.py | 2 + api/src/api/dependencies/consolidations.py | 25 ++ .../api/endpoints/consolidations/__init__.py | 9 + .../api/endpoints/consolidations/periods.py | 59 +++ .../api/endpoints/consolidations/versions.py | 28 ++ api/src/api/router.py | 10 +- api/src/common/constraint_conflicts.py | 3 + api/src/domain/consolidation_periods.py | 27 ++ api/src/domain/policy.py | 12 + api/src/repositories/consolidations.py | 30 +- api/src/services/consolidation_documents.py | 23 + api/src/services/consolidations.py | 266 ++++++++++++ api/tests/factories.py | 11 +- api/tests/test_api_consolidations.py | 402 ++++++++++++++++++ api/tests/test_api_constraint_races.py | 23 + .../test_domain_consolidation_periods.py | 22 + api/tests/test_exception_handlers.py | 1 + api/tests/test_openapi_routes.py | 30 +- api/tests/test_uow_and_errors.py | 3 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/tasks.md | 2 +- 21 files changed, 974 insertions(+), 20 deletions(-) create mode 100644 api/src/api/dependencies/consolidations.py create mode 100644 api/src/api/endpoints/consolidations/__init__.py create mode 100644 api/src/api/endpoints/consolidations/periods.py create mode 100644 api/src/api/endpoints/consolidations/versions.py create mode 100644 api/src/domain/consolidation_periods.py create mode 100644 api/src/services/consolidation_documents.py create mode 100644 api/src/services/consolidations.py create mode 100644 api/tests/test_api_consolidations.py create mode 100644 api/tests/test_domain_consolidation_periods.py diff --git a/api/src/api/dependencies/audit.py b/api/src/api/dependencies/audit.py index fb43279..4fbacd0 100644 --- a/api/src/api/dependencies/audit.py +++ b/api/src/api/dependencies/audit.py @@ -35,6 +35,7 @@ class RequestAudit: section: AuditSection, object_type: str, object_id: uuid.UUID | None = None, + consolidation_version_id: uuid.UUID | None = None, description: str | None = None, before_value: Mapping[str, Any] | None = None, after_value: Mapping[str, Any] | None = None, @@ -48,6 +49,7 @@ class RequestAudit: section=section, object_type=object_type, object_id=object_id, + consolidation_version_id=consolidation_version_id, description=description, before_value=before_value, after_value=after_value, diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py new file mode 100644 index 0000000..450468d --- /dev/null +++ b/api/src/api/dependencies/consolidations.py @@ -0,0 +1,25 @@ +"""Зависимости API консолидаций (Срез 4; R-6: сессия — только `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.api.dependencies.directories import get_scope +from src.domain.policy import VisibilityScope +from src.services.consolidations import ConsolidationService + + +def get_consolidation_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + principal: Annotated[Principal, Depends(current_user)], +) -> ConsolidationService: + """Сервис консолидаций: создание, периоды ГК, карточка версии (видимость — через ГК консолидации).""" + return ConsolidationService(session, audit, scope, principal) + + +ConsolidationServiceDep = Annotated[ConsolidationService, Depends(get_consolidation_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py new file mode 100644 index 0000000..78c2be8 --- /dev/null +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -0,0 +1,9 @@ +"""Маршруты консолидаций (specs consolidations-api, consolidated-osv): периоды ГК, версии и их содержимое.""" + +from fastapi import APIRouter + +from src.api.endpoints.consolidations import periods, versions + +router = APIRouter(tags=["consolidations"]) +router.include_router(periods.router) +router.include_router(versions.router) diff --git a/api/src/api/endpoints/consolidations/periods.py b/api/src/api/endpoints/consolidations/periods.py new file mode 100644 index 0000000..6388042 --- /dev/null +++ b/api/src/api/endpoints/consolidations/periods.py @@ -0,0 +1,59 @@ +"""`POST /consolidations` и `GET /company-groups/{id}/consolidations` (spec consolidations-api «Создание +консолидации», «Список консолидаций ГК и карточка версии»; design D-15, D-16). + +Право (`require`) проверяется до видимости; ГК вне видимости и несуществующая — одинаковый 404. +""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import ConsolidationCreate, ConsolidationPeriodOut, VersionCard + +router = APIRouter() + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +CREATE_ERRORS: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ГК или ГСК нет или вне видимости"}, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_EXISTS"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "RELATED_GROUP_MISMATCH, PERIMETER_TOO_SMALL или ошибка валидации тела" + }, +} +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ГК нет или вне видимости"} +} + + +@router.post( + "/consolidations", + response_model=VersionCard, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + responses=CREATE_ERRORS, + summary="Создать консолидацию ГК за период", +) +async def create_consolidation(data: ConsolidationCreate, service: ConsolidationServiceDep) -> VersionCard: + """Консолидация с версией №1 (`INITIATED`), периметром из членов ГСК и подхватом подтверждённых документов.""" + return await service.create(data) + + +@router.get( + "/company-groups/{group_id}/consolidations", + response_model=list[ConsolidationPeriodOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Периоды консолидации ГК и их версии", +) +async def list_consolidations( + group_id: uuid.UUID, + service: ConsolidationServiceDep, + in_work_only: bool = False, # noqa: FBT001, FBT002 +) -> list[ConsolidationPeriodOut]: + """Периоды ГК по убыванию года и квартала; версии — по убыванию номера; `in_work_only` скрывает завершённые.""" + return await service.list_periods(group_id, in_work_only=in_work_only) diff --git a/api/src/api/endpoints/consolidations/versions.py b/api/src/api/endpoints/consolidations/versions.py new file mode 100644 index 0000000..5cd99d8 --- /dev/null +++ b/api/src/api/endpoints/consolidations/versions.py @@ -0,0 +1,28 @@ +"""`/consolidations/versions/{id}` — карточка версии консолидации (spec consolidations-api «Список консолидаций ГК и +карточка версии»; design D-16). Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у +несуществующей (spec directory-policy).""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import VersionCard + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}", response_model=VersionCard, dependencies=VIEW, responses=NOT_FOUND, summary="Карточка версии" +) +async def get_version(version_id: uuid.UUID, service: ConsolidationServiceDep) -> VersionCard: + """Версия и консолидация, ГК, инициатор, блокировка, периметр с чек-листом и история статусов.""" + return await service.card(version_id) diff --git a/api/src/api/router.py b/api/src/api/router.py index 79cbf87..8460dff 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,13 +1,14 @@ """Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). -`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы -(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный -`router` собран по настройкам процесса и подключается в `src/app.py`. +`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*`, файлы +(`/legal-entities/{id}/files`) и консолидации (`/consolidations`, `/company-groups/{id}/consolidations`) — всегда, +dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и +подключается в `src/app.py`. """ from fastapi import APIRouter -from src.api.endpoints import admin, directories, files, me +from src.api.endpoints import admin, consolidations, directories, files, me from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -21,6 +22,7 @@ def build_api_router(settings: Settings) -> APIRouter: api_router.include_router(directories.router) api_router.include_router(admin.router) api_router.include_router(files.router) + api_router.include_router(consolidations.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py index e462ef1..d603203 100644 --- a/api/src/common/constraint_conflicts.py +++ b/api/src/common/constraint_conflicts.py @@ -36,6 +36,9 @@ UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас" ), "uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"), + "uq_consolidation_group_year_quarter": ConstraintConflict( + "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана" + ), "uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"), # Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан # миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py new file mode 100644 index 0000000..3783760 --- /dev/null +++ b/api/src/domain/consolidation_periods.py @@ -0,0 +1,27 @@ +"""Период консолидации: отчётная дата по году и кварталу (spec consolidations-api «Создание консолидации»). + +Чистая функция без БД. ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. +""" + +import datetime + +from src.models.enums import PeriodQuarter + +__all__ = ["reporting_date"] + +# квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года +_REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = { + PeriodQuarter.Q1: (4, 0), + PeriodQuarter.Q2: (7, 0), + PeriodQuarter.Q3: (10, 0), + PeriodQuarter.Q4: (1, 1), +} + + +def reporting_date(year: int, quarter: PeriodQuarter) -> datetime.date: + """Отчётная дата консолидации: Q1 → 01.04.year, Q2 → 01.07.year, Q3 → 01.10.year, Q4 → 01.01.year+1. + + ASSUMPTION(OQ-40): правило банка не подтверждено аналитикой (в ТЗ отчётная дата не определена). + """ + month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40) + return datetime.date(year + year_shift, month, 1) diff --git a/api/src/domain/policy.py b/api/src/domain/policy.py index 2ac7619..f190904 100644 --- a/api/src/domain/policy.py +++ b/api/src/domain/policy.py @@ -17,12 +17,24 @@ from sqlalchemy import Select, and_, or_, select from src.common.exceptions import DomainValidationError from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import ConsolidationStatus if TYPE_CHECKING: # Только для типов: домен не тянет репозитории в рантайме from src.repositories.users import UserWithAccess +# Статусы версии консолидации вне «активных» (фильтр `in_work_only` списка периодов, spec consolidations-api) +FINISHED_STATUSES = frozenset( + { + ConsolidationStatus.VERIFIED, + ConsolidationStatus.CANCELLED, + ConsolidationStatus.ARCHIVED, + ConsolidationStatus.REJECTED, + } +) + + @dataclass(frozen=True) class VisibilityScope: """Область видимости справочников: `office_id is None` — вся система (ГО).""" diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 5faba2b..7f19cf9 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -9,11 +9,12 @@ import uuid from collections import defaultdict from collections.abc import Collection, Sequence -from sqlalchemy import Select, exists, select +from sqlalchemy import Select, exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState from src.domain.policy import VisibilityScope, company_groups_visible +from src.models.access import AppUser from src.models.consolidation import ( Consolidation, ConsolidationDocument, @@ -143,6 +144,33 @@ async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> Consolida return (await session.execute(stmt)).scalar_one_or_none() +async def active_locks( + session: AsyncSession, version_ids: Collection[uuid.UUID] +) -> dict[uuid.UUID, tuple[ConsolidationLock, str]]: + """Активные блокировки версий из `version_ids` одним запросом: версия → (блокировка, ФИО владельца).""" + if not version_ids: + return {} + stmt = ( + select(ConsolidationLock, AppUser.full_name) + .join(AppUser, AppUser.id == ConsolidationLock.locked_by) + .where(ConsolidationLock.consolidation_version_id.in_(version_ids), ConsolidationLock.released_at.is_(None)) + ) + return {row[0].consolidation_version_id: (row[0], row[1]) for row in await session.execute(stmt)} + + +async def members_counts(session: AsyncSession, version_ids: Collection[uuid.UUID]) -> dict[uuid.UUID, int]: + """Размер периметра каждой версии из `version_ids` одним запросом (версия без членов — 0).""" + if not version_ids: + return {} + stmt = ( + select(ConsolidationMember.consolidation_version_id, func.count(ConsolidationMember.id)) + .where(ConsolidationMember.consolidation_version_id.in_(version_ids)) + .group_by(ConsolidationMember.consolidation_version_id) + ) + counts = {row[0]: row[1] for row in await session.execute(stmt)} + return {version_id: counts.get(version_id, 0) for version_id in version_ids} + + async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[ConsolidationMember, LegalEntity]]: """Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка).""" stmt = ( diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py new file mode 100644 index 0000000..68d63fe --- /dev/null +++ b/api/src/services/consolidation_documents.py @@ -0,0 +1,23 @@ +"""Привязка подтверждённых документов к версии консолидации (spec documents-api «Подхват документов»; design D-5). + +ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор +подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок +файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызов из +`services.consolidations.ConsolidationService.create` сохраняет. +""" + +import uuid +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.consolidation import ConsolidationVersion + + +async def bind_confirmed( + session: AsyncSession, # noqa: ARG001 - сигнатура окончательная, тело — заглушка до Task 6 + version: ConsolidationVersion, # noqa: ARG001 + entity_ids: Sequence[uuid.UUID], # noqa: ARG001 +) -> int: + """Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок.""" + return 0 diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py new file mode 100644 index 0000000..4ec8a7e --- /dev/null +++ b/api/src/services/consolidations.py @@ -0,0 +1,266 @@ +"""Сервис консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание +консолидации», «Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; +audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16). + +Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404 +с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК, `is_head_entity = false`; +ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя +не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → +`IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`). +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.consolidation_periods import reporting_date +from src.domain.policy import FINISHED_STATUSES, VisibilityScope +from src.models.access import AppUser +from src.models.consolidation import ( + Consolidation, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import LegalEntity +from src.models.enums import ( + AuditAction, + AuditSection, + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + RelatedGroupKind, +) +from src.repositories import consolidations as repo +from src.repositories import directories as directories_repo +from src.repositories.users import UserWithAccess +from src.schemas.consolidations import ( + ConsolidationCreate, + ConsolidationPeriodOut, + HistoryOut, + LockOut, + MemberOut, + RefOut, + VersionBrief, + VersionCard, +) +from src.services import consolidation_documents +from src.services.company_groups import COMPANY_GROUP_NOT_FOUND +from src.services.related_groups import RELATED_GROUP_NOT_FOUND + +CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей (spec directory-policy) +VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей +OBJECT_TYPE = "consolidation_version" +MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) +FIRST_VERSION_NO = 1 + + +def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: + return LockOut( + locked_by=lock.locked_by, + locked_by_name=locked_by_name, + locked_at=lock.locked_at, + taken_over_from=lock.taken_over_from, + takeover_reason=lock.takeover_reason, + ) + + +class ConsolidationService: + """Консолидации ГК от имени текущего пользователя в его области видимости.""" + + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, principal: UserWithAccess + ) -> None: + self._session = session + self._audit = audit + self._scope = scope + self._principal = principal + + async def create(self, data: ConsolidationCreate) -> VersionCard: + """Консолидация ГК за период с версией №1 (`INITIATED`), периметром из ГСК, историей и аудитом. + + Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422 + `RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет + (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё. + """ + group = await directories_repo.get_company_group(self._session, self._scope, data.company_group_id) + if group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + related = await directories_repo.get_related_group(self._session, self._scope, data.related_group_id) + if related is None: + raise NotFoundError(RELATED_GROUP_NOT_FOUND) + if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id: + raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК") + entities = [entity for _, entity in await directories_repo.current_members(self._session, related.id)] + if len(entities) < MIN_PERIMETER: + raise DomainValidationError( + "PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entities)}" + ) + if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter): + raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана") + + consolidation, version = await self._insert(group.id, data, entities) + await self._audit.record( + action=AuditAction.CONSOLIDATION_CREATE, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + after_value={ + "consolidation_id": consolidation.id, + "year": consolidation.year, + "quarter": consolidation.quarter, + "members": sorted(entity.id for entity in entities), + }, + ) + return await self._card(version, consolidation) + + async def list_periods(self, group_id: uuid.UUID, *, in_work_only: bool) -> list[ConsolidationPeriodOut]: + """Периоды ГК по убыванию года и квартала, в каждом — версии по убыванию `version_no`. + + `in_work_only` оставляет версии со статусом не из {`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}; период + без оставшихся версий в ответ не попадает. ГК вне видимости и несуществующая — один и тот же 404. + """ + if await directories_repo.get_company_group(self._session, self._scope, group_id) is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + periods = await repo.list_periods(self._session, self._scope, group_id) + if in_work_only: + periods = [ + (consolidation, [v for v in versions if v.status not in FINISHED_STATUSES]) + for consolidation, versions in periods + ] + periods = [(consolidation, versions) for consolidation, versions in periods if versions] + version_ids = [version.id for _, versions in periods for version in versions] + locks = await repo.active_locks(self._session, version_ids) + counts = await repo.members_counts(self._session, version_ids) + return [ + ConsolidationPeriodOut( + consolidation_id=consolidation.id, + year=consolidation.year, + quarter=consolidation.quarter, + reporting_date=consolidation.reporting_date, + versions=[self._brief(version, locks.get(version.id), counts[version.id]) for version in versions], + ) + for consolidation, versions in periods + ] + + async def card(self, version_id: uuid.UUID) -> VersionCard: + """Карточка версии; версия ГК вне видимости и несуществующая — один и тот же 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + return await self._card(*found) + + async def _insert( + self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity] + ) -> tuple[Consolidation, ConsolidationVersion]: + """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" + now = utc_now() + user = self._principal.user + consolidation = Consolidation( + company_group_id=group_id, + year=data.year, + quarter=data.quarter, + reporting_date=reporting_date(data.year, data.quarter), + created_at=now, + ) + self._session.add(consolidation) + await self._session.flush() # гонку за (ГК, год, квартал) ловит UNIQUE → IntegrityError → 409 + version = ConsolidationVersion( + consolidation_id=consolidation.id, + version_no=FIRST_VERSION_NO, + status=ConsolidationStatus.INITIATED, + scenario=ConsolidationScenario.CREATE, + approach=ConsolidationApproach.BASIC, + goodwill_enabled=False, + initiator_id=user.id, + initiator_office_id=user.regional_office_id, + is_in_work=True, + created_at=now, + created_by=user.id, + ) + self._session.add(version) + await self._session.flush() + self._session.add_all( + ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False) + for entity in entities + ) + self._session.add( + ConsolidationStatusHistory( + version_id=version.id, + from_status=None, + to_status=ConsolidationStatus.INITIATED, + changed_by=user.id, + changed_at=now, + ) + ) + await self._session.flush() + await consolidation_documents.bind_confirmed(self._session, version, [entity.id for entity in entities]) + return consolidation, version + + @staticmethod + def _brief( + version: ConsolidationVersion, lock: tuple[ConsolidationLock, str] | None, members_count: int + ) -> VersionBrief: + return VersionBrief( + id=version.id, + version_no=version.version_no, + status=version.status, + scenario=version.scenario, + approach=version.approach, + lock=_lock_out(*lock) if lock is not None else None, + created_at=version.created_at, + members_count=members_count, + ) + + async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard: + """Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка. + + Чек-лист у членов — пустой список до Task 8 (`domain.checklist.build`). + """ + group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) + initiator = await self._session.get(AppUser, version.initiator_id) + office = await directories_repo.get_regional_office(self._session, version.initiator_office_id) + if group is None or initiator is None or office is None: # FK гарантируют наличие; группа видна по построению + raise NotFoundError(VERSION_NOT_FOUND) + members = await repo.members(self._session, version.id) + locks = await repo.active_locks(self._session, [version.id]) + history = await repo.history(self._session, version.id) + brief = self._brief(version, locks.get(version.id), len(members)) + return VersionCard( + **brief.model_dump(), + consolidation_id=consolidation.id, + year=consolidation.year, + quarter=consolidation.quarter, + reporting_date=consolidation.reporting_date, + company_group=RefOut(id=group.id, name=group.name), + initiator=RefOut(id=initiator.id, name=initiator.full_name), + initiator_office=RefOut(id=office.id, name=office.name), + goodwill_enabled=version.goodwill_enabled, + is_in_work=version.is_in_work, + previous_version_id=version.previous_version_id, + members=[ + MemberOut( + id=entity.id, + name=entity.name, + inn=entity.inn, + is_head_entity=member.is_head_entity, + checklist=[], # Task 8: ячейки по правилам подхода + ) + for member, entity in members + ], + history=[ + HistoryOut( + from_status=record.from_status, + to_status=record.to_status, + changed_by=record.changed_by, + changed_at=record.changed_at, + comment=record.comment, + ) + for record in history + ], + ) diff --git a/api/tests/factories.py b/api/tests/factories.py index 99be452..1a3504d 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -28,6 +28,7 @@ from sqlalchemy import delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult +from src.domain.consolidation_periods import reporting_date from src.domain.osv_mapping import build_entries, document_fields from src.models.access import AppUser, Role, UserRole from src.models.accounting import ChartOfAccount @@ -72,13 +73,6 @@ from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) FACTORY_BUCKET = "arm-ka" -# первый день месяца, следующего за кварталом (reporting_date, ASSUMPTION(OQ-40)); Q4 — 1 января следующего года -REPORTING_DATES = { - PeriodQuarter.Q1: (4, 0), - PeriodQuarter.Q2: (7, 0), - PeriodQuarter.Q3: (10, 0), - PeriodQuarter.Q4: (1, 1), -} # статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию FINISHED_STATUSES = frozenset( { @@ -442,12 +436,11 @@ async def make_consolidation( # noqa: PLR0913 - сигнатура фабрик `reporting_date` — по правилу спеки: Q1 → 01.04, Q2 → 01.07, Q3 → 01.10, Q4 → 01.01 следующего года. """ - month, year_shift = REPORTING_DATES[quarter] consolidation = Consolidation( company_group_id=group.id, year=year, quarter=quarter, - reporting_date=datetime.date(year + year_shift, month, 1), + reporting_date=reporting_date(year, quarter), created_at=_now(), ) session.add(consolidation) diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py new file mode 100644 index 0000000..db24a60 --- /dev/null +++ b/api/tests/test_api_consolidations.py @@ -0,0 +1,402 @@ +"""API консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание консолидации», +«Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; audit-trail +`CONSOLIDATION_CREATE`; design D-15, D-16).""" + +import uuid +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ( + Consolidation, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_consolidation, + make_legal_entity, + make_lock, + make_membership, + make_related_group, + make_version, +) + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" +KA_GO = "ka_go@armka.local" +CURATOR = "curator_go@armka.local" +EXPERT = "expert@armka.local" +CONSOLIDATIONS = "/consolidations" +VERSIONS = "/consolidations/versions" + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _scenario( + session: AsyncSession, *, office_name: str = ROSTOV, members: int = 2 +) -> tuple[CompanyGroup, RelatedGroup, list[LegalEntity]]: + """ГК филиала с ГСК, в которой `members` текущих членов.""" + group = await make_company_group(session, name="ГК Русский бисквит", office=await _office(session, office_name)) + gsk = await make_related_group(session, group=group, name="ГСК Русский бисквит") + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)] + for entity in entities: + await make_membership(session, related_group=gsk, entity=entity) + return group, gsk, entities + + +def _body(group: CompanyGroup, gsk: RelatedGroup, year: int = 2024, quarter: str = "Q3") -> dict[str, Any]: + return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter} + + +async def _count(session: AsyncSession, model: type) -> int: + return (await session.execute(select(func.count()).select_from(model))).scalar_one() + + +@pytest.mark.integration +async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, entities = await _scenario(seeded) + client = await auth_client(KA_RF) + user = await _user(seeded, KA_RF) + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_CREATED, response.text + card = response.json() + assert (card["version_no"], card["status"], card["scenario"], card["approach"]) == ( + 1, + "INITIATED", + "CREATE", + "BASIC", + ) + assert (card["year"], card["quarter"], card["reporting_date"]) == (2024, "Q3", "2024-10-01") + assert (card["goodwill_enabled"], card["is_in_work"], card["previous_version_id"]) == (False, True, None) + assert card["lock"] is None + assert card["members_count"] == len(entities) + assert card["company_group"] == {"id": str(group.id), "name": "ГК Русский бисквит"} + assert card["initiator"] == {"id": str(user.id), "name": user.full_name} + assert card["initiator_office"] == {"id": str(user.regional_office_id), "name": ROSTOV} + assert [member["name"] for member in card["members"]] == ["ООО Член 0", "ООО Член 1"] + assert {member["id"] for member in card["members"]} == {str(entity.id) for entity in entities} + assert all(member["is_head_entity"] is False and member["checklist"] == [] for member in card["members"]) + assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [(None, "INITIATED")] + assert card["history"][0]["changed_by"] == str(user.id) + + consolidation = (await seeded.execute(select(Consolidation))).scalar_one() + assert consolidation.related_group_id is None # фиксируется при верификации (Срез 7) + assert str(consolidation.id) == card["consolidation_id"] + version = (await seeded.execute(select(ConsolidationVersion))).scalar_one() + assert (version.initiator_id, version.created_by, version.is_in_work) == (user.id, user.id, True) + assert await _count(seeded, ConsolidationLock) == 0 + assert await _count(seeded, ConsolidationMember) == len(entities) + assert await _count(seeded, ConsolidationStatusHistory) == 1 + + event = ( + await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE)) + ).scalar_one() + assert (event.section, event.object_type, event.object_id) == ( + AuditSection.CONSOLIDATION, + "consolidation_version", + version.id, + ) + assert event.consolidation_version_id == version.id + assert event.user_id == user.id + assert event.before_value is None + assert event.after_value == { + "consolidation_id": str(consolidation.id), + "year": 2024, + "quarter": "Q3", + "members": sorted(str(entity.id) for entity in entities), + } + + +@pytest.mark.integration +async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, 2024, "Q4")) + assert response.status_code == HTTP_CREATED, response.text + assert response.json()["reporting_date"] == "2025-01-01" + + +@pytest.mark.integration +async def test_create_with_single_member_is_perimeter_too_small( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + group, gsk, _ = await _scenario(seeded, members=1) + client = await auth_client(KA_RF) + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL" + assert await _count(seeded, Consolidation) == 0 + assert await _count(seeded, ConsolidationVersion) == 0 + created = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE) + assert (await seeded.execute(created)).scalar_one() == 0 + + +@pytest.mark.integration +async def test_create_ignores_closed_memberships(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """В периметр входят только текущие члены ГСК.""" + group, gsk, _ = await _scenario(seeded, members=2) + former = await make_legal_entity(seeded, name="ООО Бывший", group=group) + await make_membership(seeded, related_group=gsk, entity=former, is_current=False) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_CREATED, response.text + assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]] + + +@pytest.mark.integration +async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + assert (await client.post(CONSOLIDATIONS, json=_body(group, gsk))).status_code == HTTP_CREATED + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "CONSOLIDATION_EXISTS" + assert await _count(seeded, Consolidation) == 1 + assert await _count(seeded, ConsolidationVersion) == 1 + + +@pytest.mark.integration +@pytest.mark.parametrize("year", [1999, 2101]) +async def test_create_year_out_of_range_is_unprocessable( + auth_client: AuthClientFactory, seeded: AsyncSession, year: int +) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year)) + assert response.status_code == HTTP_UNPROCESSABLE + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_create_gsk_must_belong_to_group_and_be_gsk(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, _, _ = await _scenario(seeded) + other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV)) + foreign_gsk = await make_related_group(seeded, group=other_group) + gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ") + client = await auth_client(KA_RF) + + for related in (foreign_gsk, gsz): + response = await client.post(CONSOLIDATIONS, json=_body(group, related)) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH" + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_create_unknown_or_foreign_objects_are_not_found( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + group, gsk, _ = await _scenario(seeded) + foreign_group, foreign_gsk, _ = await _scenario(seeded, office_name=KRASNODAR) + client = await auth_client(KA_RF) + + unknown_group = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "company_group_id": str(uuid.uuid4())}) + foreign = await client.post(CONSOLIDATIONS, json=_body(foreign_group, foreign_gsk)) + unknown_gsk = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "related_group_id": str(uuid.uuid4())}) + + assert unknown_group.status_code == HTTP_NOT_FOUND + assert foreign.status_code == HTTP_NOT_FOUND + assert foreign.json() == unknown_group.json() + assert unknown_gsk.status_code == HTTP_NOT_FOUND + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +@pytest.mark.parametrize("email", [EXPERT, CURATOR]) +async def test_create_requires_consolidation_edit( + auth_client: AuthClientFactory, seeded: AsyncSession, email: str +) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(email) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_FORBIDDEN + assert "CONSOLIDATION_EDIT" in response.json()["detail"] + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_go_user_creates_for_any_office(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded, office_name=KRASNODAR) + client = await auth_client(KA_GO) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_CREATED, response.text + go_office = (await _user(seeded, KA_GO)).regional_office_id + assert response.json()["initiator_office"]["id"] == str(go_office) + + +async def _tree(session: AsyncSession) -> tuple[CompanyGroup, dict[str, ConsolidationVersion]]: + """ГК Ростовского филиала: 2024 Q1 (версии 1 и 2), 2024 Q3 (версия 1), 2023 Q4 (версия 1).""" + group, _, entities = await _scenario(session) + initiator = await _user(session, KA_RF) + q1, q1v1 = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=initiator + ) + q1v2 = await make_version(session, consolidation=q1, version_no=2, members=entities[:1], initiator=initiator) + _, q3v1 = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=initiator + ) + _, old = await make_consolidation( + session, + group=group, + year=2023, + quarter=PeriodQuarter.Q4, + members=entities, + initiator=initiator, + status=ConsolidationStatus.CANCELLED, + ) + return group, {"q1v1": q1v1, "q1v2": q1v2, "q3v1": q3v1, "old": old} + + +@pytest.mark.integration +async def test_list_periods_tree_order_and_members_count(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + owner = await _user(seeded, RKA) + await make_lock(seeded, version=versions["q1v2"], user=owner) + await make_lock(seeded, version=versions["q1v1"], user=owner, released=True) + client = await auth_client(KA_RF) + + response = await client.get(f"/company-groups/{group.id}/consolidations") + + assert response.status_code == HTTP_OK, response.text + periods = response.json() + assert [(item["year"], item["quarter"]) for item in periods] == [(2024, "Q3"), (2024, "Q1"), (2023, "Q4")] + assert [item["reporting_date"] for item in periods] == ["2024-10-01", "2024-04-01", "2024-01-01"] + q3, q1, _ = periods + assert [(v["id"], v["version_no"], v["members_count"]) for v in q3["versions"]] == [ + (str(versions["q3v1"].id), 1, 2) + ] + assert [(v["id"], v["version_no"], v["members_count"]) for v in q1["versions"]] == [ + (str(versions["q1v2"].id), 2, 1), + (str(versions["q1v1"].id), 1, 2), + ] + v2, v1 = q1["versions"] + assert v2["lock"]["locked_by"] == str(owner.id) + assert v2["lock"]["locked_by_name"] == owner.full_name + assert v2["lock"]["taken_over_from"] is None + assert v1["lock"] is None # снятая блокировка не активна + assert q3["versions"][0]["lock"] is None + assert {"status", "scenario", "approach", "created_at"} <= set(v1) + assert periods[0]["consolidation_id"] == q3["consolidation_id"] + + +@pytest.mark.integration +async def test_list_periods_in_work_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + client = await auth_client(KA_RF) + # одну версию 2024 Q1 переводим в завершённое состояние, у периода остаётся другая + versions["q1v2"].status = ConsolidationStatus.VERIFIED + versions["q1v2"].is_in_work = False + await seeded.flush() + + response = await client.get(f"/company-groups/{group.id}/consolidations", params={"in_work_only": "true"}) + + assert response.status_code == HTTP_OK, response.text + periods = response.json() + # 2023 Q4 (единственная версия CANCELLED) пропадает, у 2024 Q1 остаётся только версия 1 + assert [(item["year"], item["quarter"]) for item in periods] == [(2024, "Q3"), (2024, "Q1")] + assert [v["version_no"] for v in periods[1]["versions"]] == [1] + everything = await client.get(f"/company-groups/{group.id}/consolidations", params={"in_work_only": "false"}) + assert len(everything.json()) == len(periods) + 1 + + +@pytest.mark.integration +async def test_list_periods_hidden_group_is_not_found_and_empty_group_is_empty( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + foreign, _, _ = await _scenario(seeded, office_name=KRASNODAR) + empty = await make_company_group(seeded, name="ГК без периодов", office=await _office(seeded, ROSTOV)) + client = await auth_client(KA_RF) + + hidden = await client.get(f"/company-groups/{foreign.id}/consolidations") + unknown = await client.get(f"/company-groups/{uuid.uuid4()}/consolidations") + nothing = await client.get(f"/company-groups/{empty.id}/consolidations") + + assert hidden.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (nothing.status_code, nothing.json()) == (HTTP_OK, []) + + +@pytest.mark.integration +async def test_list_periods_foreign_office_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, _ = await _tree(seeded) + client = await auth_client("client_manager@armka.local") + # у менеджера клиента право CONSOLIDATION_VIEW есть, но ГК другого филиала (Краснодар) — вне видимости + assert (await client.get(f"/company-groups/{group.id}/consolidations")).status_code == HTTP_NOT_FOUND + + +@pytest.mark.integration +async def test_version_card_with_history_and_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + holder = await _user(seeded, RKA) + await make_lock(seeded, version=versions["q3v1"], user=holder) + client = await auth_client(KA_RF) + + response = await client.get(f"{VERSIONS}/{versions['old'].id}") + locked = await client.get(f"{VERSIONS}/{versions['q3v1'].id}") + + assert response.status_code == HTTP_OK, response.text + card = response.json() + assert card["id"] == str(versions["old"].id) + assert (card["status"], card["is_in_work"], card["version_no"]) == ("CANCELLED", False, 1) + assert (card["year"], card["quarter"]) == (2023, "Q4") + assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [ + (None, "INITIATED"), + ("INITIATED", "CANCELLED"), + ] + assert card["company_group"]["id"] == str(group.id) + assert locked.json()["lock"]["locked_by_name"] == holder.full_name + + +@pytest.mark.integration +async def test_version_card_visible_to_go_curator_but_not_to_foreign_office( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + _, versions = await _tree(seeded) + version_id = versions["q3v1"].id + curator = await auth_client(CURATOR) + foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал + random_id = uuid.uuid4() + + assert (await curator.get(f"{VERSIONS}/{version_id}")).status_code == HTTP_OK + hidden = await foreign.get(f"{VERSIONS}/{version_id}") + unknown = await foreign.get(f"{VERSIONS}/{random_id}") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + + +@pytest.mark.integration +async def test_version_card_requires_consolidation_view(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + _, versions = await _tree(seeded) + client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет + response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}") + assert response.status_code == HTTP_FORBIDDEN diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py index 653848f..30deff4 100644 --- a/api/tests/test_api_constraint_races.py +++ b/api/tests/test_api_constraint_races.py @@ -223,3 +223,26 @@ async def test_foreign_key_violation_stays_generic_conflict( assert response.status_code == HTTP_CONFLICT, response.text assert response.json() == {"detail": GENERIC_DETAIL} + + +@pytest.mark.integration +async def test_consolidation_period_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Два создания одного периода (ГК, год, квартал): pre-check пропустил, UNIQUE отклонил → `CONSOLIDATION_EXISTS`.""" + office = ( + await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) + ).scalar_one() + group = await make_company_group(seeded, office=office) + gsk = await make_related_group(seeded, group=group) + for number in range(2): + entity = await make_legal_entity(seeded, name=f"ООО Член {number}", group=group) + await make_membership(seeded, related_group=gsk, entity=entity) + client = await auth_client("ka_rf@armka.local") + body = {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": 2024, "quarter": "Q3"} + assert (await client.post("/consolidations", json=body)).status_code == HTTP_CREATED + monkeypatch.setattr("src.repositories.consolidations.get_consolidation_for_group", _free(None)) + + response = await client.post("/consolidations", json=body) + + _assert_coded(response, "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана", str(group.id)) diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py new file mode 100644 index 0000000..7a04477 --- /dev/null +++ b/api/tests/test_domain_consolidation_periods.py @@ -0,0 +1,22 @@ +"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40).""" + +import datetime + +import pytest + +from src.domain.consolidation_periods import reporting_date +from src.models.enums import PeriodQuarter + + +@pytest.mark.parametrize( + ("year", "quarter", "expected"), + [ + (2024, PeriodQuarter.Q1, datetime.date(2024, 4, 1)), + (2024, PeriodQuarter.Q2, datetime.date(2024, 7, 1)), + (2024, PeriodQuarter.Q3, datetime.date(2024, 10, 1)), + (2024, PeriodQuarter.Q4, datetime.date(2025, 1, 1)), + (2100, PeriodQuarter.Q4, datetime.date(2101, 1, 1)), + ], +) +def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None: + assert reporting_date(year, quarter) == expected diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index 7080d25..df96806 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -185,6 +185,7 @@ def test_registry_codes_and_messages_match_service_precheck_texts() -> None: "uq_app_user_email": "EMAIL_EXISTS", "uq_related_group_member_group_entity_current": "MEMBER_EXISTS", "uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP", + "uq_consolidation_group_year_quarter": "CONSOLIDATION_EXISTS", } # у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].message == "ЮЛ с таким ИНН уже существует" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 4fa95cb..47275be 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -1,4 +1,5 @@ -"""OpenAPI содержит все маршруты Среза 1 (specs directories-api, directory-import, users-admin) без дублей моделей. +"""OpenAPI содержит все маршруты Срезов 1–4 (specs directories-api, directory-import, users-admin, +consolidations-api) без дублей моделей. Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`; проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью. @@ -50,10 +51,22 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, + # consolidations-api (Срез 4) + "/api/v1/consolidations": {"post"}, + "/api/v1/company-groups/{group_id}/consolidations": {"get"}, + "/api/v1/consolidations/versions/{version_id}": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно -CHECKED_PREFIXES = (D, A, "/api/v1/legal-entities", "/api/v1/files", "/api/v1/documents") +CHECKED_PREFIXES = ( + D, + A, + "/api/v1/legal-entities", + "/api/v1/files", + "/api/v1/documents", + "/api/v1/consolidations", + "/api/v1/company-groups", +) # Модели, которые обязаны быть в схеме ровно один раз под коротким именем SINGLE_MODELS = [ @@ -66,6 +79,8 @@ SINGLE_MODELS = [ "FileOut", "DocumentOut", "EntryOut", + "VersionCard", + "ConsolidationPeriodOut", ] @@ -100,3 +115,14 @@ def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None: for model in SINGLE_MODELS: assert model in names, model assert not [name for name in names if "__" in name or "-" in name], names + + +def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any]) -> None: + """Создание — 201 с карточкой версии и ошибки 404/409/422; чтение карточки и списка — 404 (ГК вне видимости).""" + create = schema["paths"]["/api/v1/consolidations"]["post"]["responses"] + assert {"201", "404", "409", "422"} <= set(create) + assert create["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard") + card = schema["paths"]["/api/v1/consolidations/versions/{version_id}"]["get"]["responses"] + assert {"200", "404"} <= set(card) + listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"] + assert {"200", "404"} <= set(listing) diff --git a/api/tests/test_uow_and_errors.py b/api/tests/test_uow_and_errors.py index 9d95f08..37f6161 100644 --- a/api/tests/test_uow_and_errors.py +++ b/api/tests/test_uow_and_errors.py @@ -194,11 +194,13 @@ async def test_request_audit_delegates_with_request_context() -> None: service=cast(AuditService, service), user_id=user_id, ip_address="10.0.0.1", user_agent="pytest-agent" ) object_id = uuid.uuid4() + version_id = uuid.uuid4() result = await audit.record( action=AuditAction.GROUP_CHANGE, section=AuditSection.DIRECTORIES, object_type="company_group", object_id=object_id, + consolidation_version_id=version_id, before_value={"name": "А"}, after_value={"name": "Б"}, changed_field="name", @@ -210,6 +212,7 @@ async def test_request_audit_delegates_with_request_context() -> None: "section": AuditSection.DIRECTORIES, "object_type": "company_group", "object_id": object_id, + "consolidation_version_id": version_id, "description": None, "before_value": {"name": "А"}, "after_value": {"name": "Б"}, diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index e02ed81..e7b1a5b 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -84,9 +84,9 @@ - `create`: ГК видима (`company_group_is_visible`) → ГСК `get_related_group` + `kind == gsk` + `company_group_id == group` → `current_members` → ≥ 2 → `Consolidation(reporting_date)` + `ConsolidationVersion(version_no=1, INITIATED, CREATE, BASIC, initiator_id, initiator_office_id = principal.user.regional_office_id, created_by, created_at)` + `ConsolidationMember` × N + `ConsolidationStatusHistory(None → INITIATED, changed_by)` + подхват `consolidation_documents.bind_confirmed(version, entity_ids)` (Task 6; до него — заглушка `return 0`) + `audit(CONSOLIDATION_CREATE, object = consolidation_version, after = {consolidation_id, year, quarter, members: [...]})`. - Маршруты: `POST /consolidations` (201), `GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`; права `require("CONSOLIDATION_EDIT")` / `require("CONSOLIDATION_VIEW")`. -- [ ] **Step 1: Тесты.** Домен: `reporting_date(2024, Q1) == date(2024, 4, 1)`, `Q4 → date(2025, 1, 1)`. API (integration, `auth_client`): сценарии спеки «Создание» (РБ Q3 2024, один член, повтор периода, `year = 1999` → 422), «Список и карточка» (дерево, чужой филиал 404 с телом как у случайного uuid, `in_work_only`), `curator_go` читает карточку, `expert` без `CONSOLIDATION_EDIT` → 403 на создании; `CONSOLIDATION_CREATE` с `consolidation_version_id`. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`. +- [x] **Step 1: Тесты.** Домен: `reporting_date(2024, Q1) == date(2024, 4, 1)`, `Q4 → date(2025, 1, 1)`. API (integration, `auth_client`): сценарии спеки «Создание» (РБ Q3 2024, один член, повтор периода, `year = 1999` → 422), «Список и карточка» (дерево, чужой филиал 404 с телом как у случайного uuid, `in_work_only`), `curator_go` читает карточку, `expert` без `CONSOLIDATION_EDIT` → 403 на создании; `CONSOLIDATION_CREATE` с `consolidation_version_id`. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`. --- diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 1f4b71d..a5ad074 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -18,7 +18,7 @@ ## 2. Версии, периметр, блокировка, статусы -- [ ] 2.1 **Создание, список, карточка.** `schemas/consolidations.py` (D-16), `services/consolidations.py:create/list_periods/card`, +- [x] 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: -- 2.47.2 From b2fa59061d611579a9a29db021982b244af3fad3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 04:16:23 +0400 Subject: [PATCH 07/27] =?UTF-8?q?feat(consolidation):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B8=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D0=BE=D1=81=D1=82=D0=B8,?= =?UTF-8?q?=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=BC,=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20=D0=A1=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D0=B0=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain/policy: IN_WORK_STATUSES, ensure_version_editable (VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER) - services/consolidation_locks: take/release, перехват с причиной, release_active_lock; FOR UPDATE версии первой (D-9), вставка под SAVEPOINT, нарушение uq_consolidation_lock_version_active -> 409 LOCKED_BY_OTHER с владельцем - services/consolidation_status: MANUAL_TRANSITIONS, transition (единственное место смены статуса: история + аудит, CANCELLED снимает блокировку), ConsolidationStatusService.change - маршруты POST/DELETE …/lock, POST …/status; реестр constraint_conflicts - тесты: таблица ensure_version_editable, сценарии спеки, гонка двух POST …/lock, перехват во время правки и аннулирования (две сессии, Review Focus 4) - минорные T3.1 (FINISHED_STATUSES из policy в фабриках), T3.2 (удалена CONSOLIDATION_NOT_FOUND) - план: шаги Task 4; tasks.md 2.2, 2.3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/consolidations.py | 26 + .../api/endpoints/consolidations/__init__.py | 4 +- api/src/api/endpoints/consolidations/locks.py | 58 +++ .../api/endpoints/consolidations/status.py | 36 ++ api/src/common/constraint_conflicts.py | 5 + api/src/domain/policy.py | 42 +- api/src/schemas/consolidations.py | 8 +- api/src/services/consolidation_locks.py | 191 +++++++ api/src/services/consolidation_status.py | 140 ++++++ api/src/services/consolidations.py | 1 - api/tests/factories.py | 13 +- api/tests/test_api_consolidation_locks.py | 469 ++++++++++++++++++ api/tests/test_api_consolidation_status.py | 445 +++++++++++++++++ api/tests/test_api_constraint_races.py | 57 ++- api/tests/test_domain_policy.py | 64 ++- api/tests/test_exception_handlers.py | 1 + api/tests/test_openapi_routes.py | 15 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/tasks.md | 4 +- 19 files changed, 1561 insertions(+), 24 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/locks.py create mode 100644 api/src/api/endpoints/consolidations/status.py create mode 100644 api/src/services/consolidation_locks.py create mode 100644 api/src/services/consolidation_status.py create mode 100644 api/tests/test_api_consolidation_locks.py create mode 100644 api/tests/test_api_consolidation_status.py diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 450468d..429ca3e 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -9,6 +9,8 @@ from src.api.dependencies.auth import Principal, current_user from src.api.dependencies.db import SessionDep from src.api.dependencies.directories import get_scope from src.domain.policy import VisibilityScope +from src.services.consolidation_locks import ConsolidationLockService +from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService @@ -23,3 +25,27 @@ def get_consolidation_service( ConsolidationServiceDep = Annotated[ConsolidationService, Depends(get_consolidation_service)] + + +def get_lock_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + principal: Annotated[Principal, Depends(current_user)], +) -> ConsolidationLockService: + """Сервис блокировки версии: взять в работу, перехват, снятие.""" + return ConsolidationLockService(session, audit, scope, principal) + + +def get_status_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + principal: Annotated[Principal, Depends(current_user)], +) -> ConsolidationStatusService: + """Сервис ручной смены статуса версии (Срез 4: `CANCELLED`, возврат в `INITIATED`).""" + return ConsolidationStatusService(session, audit, scope, principal) + + +ConsolidationLockServiceDep = Annotated[ConsolidationLockService, Depends(get_lock_service)] +ConsolidationStatusServiceDep = Annotated[ConsolidationStatusService, Depends(get_status_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 78c2be8..c23ab35 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,8 +2,10 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import periods, versions +from src.api.endpoints.consolidations import locks, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) router.include_router(versions.router) +router.include_router(locks.router) +router.include_router(status.router) diff --git a/api/src/api/endpoints/consolidations/locks.py b/api/src/api/endpoints/consolidations/locks.py new file mode 100644 index 0000000..3053628 --- /dev/null +++ b/api/src/api/endpoints/consolidations/locks.py @@ -0,0 +1,58 @@ +"""`/consolidations/versions/{id}/lock` — взять версию в работу, перехватить, снять (spec consolidations-api «Взять в +работу, перехват, снятие»; design D-7). Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у +несуществующей.""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, Response, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationLockServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import LockIn, LockOut + +router = APIRouter(prefix="/consolidations/versions") + +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +TAKE_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: {"model": LockOut, "description": "версия уже в работе у вас — блокировка без изменений"}, + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "VERSION_NOT_EDITABLE, LOCKED_BY_OTHER"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "takeover_reason длиннее 1000 символов"}, +} +RELEASE_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "NOT_LOCK_OWNER"}, +} + + +@router.post( + "/{version_id}/lock", + response_model=LockOut, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + responses=TAKE_RESPONSES, + summary="Взять версию в работу (перехват — с причиной)", +) +async def take_lock( + version_id: uuid.UUID, service: ConsolidationLockServiceDep, response: Response, data: LockIn | None = None +) -> LockOut: + """Свободная версия — 201; своя блокировка — 200 без изменений; чужая — 409, с `takeover_reason` — перехват 201.""" + lock, created = await service.take(version_id, data or LockIn()) + if not created: + response.status_code = status.HTTP_200_OK + return lock + + +@router.delete( + "/{version_id}/lock", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=EDIT, + responses=RELEASE_RESPONSES, + summary="Снять свою блокировку версии", +) +async def release_lock(version_id: uuid.UUID, service: ConsolidationLockServiceDep) -> Response: + """Своя активная — снимается; активной нет — тоже 204; чужая — 409 `NOT_LOCK_OWNER`.""" + await service.release(version_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/api/src/api/endpoints/consolidations/status.py b/api/src/api/endpoints/consolidations/status.py new file mode 100644 index 0000000..0bc0ca7 --- /dev/null +++ b/api/src/api/endpoints/consolidations/status.py @@ -0,0 +1,36 @@ +"""`POST /consolidations/versions/{id}/status` — ручная смена статуса версии Среза 4 (spec consolidations-api +«Статусы Среза 4»; directory-policy «редактируемость версии»; design D-6, D-8).""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationStatusServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import StatusIn, VersionCard + +router = APIRouter(prefix="/consolidations/versions") + +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER, TRANSITION_NOT_ALLOWED", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "COMMENT_REQUIRED или ошибка валидации тела"}, +} + + +@router.post( + "/{version_id}/status", + response_model=VersionCard, + dependencies=EDIT, + responses=RESPONSES, + summary="Сменить статус версии вручную", +) +async def change_status(version_id: uuid.UUID, data: StatusIn, service: ConsolidationStatusServiceDep) -> VersionCard: + """`CANCELLED` из статуса «в работе» (с комментарием) или возврат в `INITIATED`; нужна своя блокировка.""" + return await service.change(version_id, data) diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py index d603203..0c8a015 100644 --- a/api/src/common/constraint_conflicts.py +++ b/api/src/common/constraint_conflicts.py @@ -39,6 +39,11 @@ UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { "uq_consolidation_group_year_quarter": ConstraintConflict( "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана" ), + # блокировки версии выстраиваются `FOR UPDATE` версии; индекс — последний рубеж (`consolidation_locks._insert` + # ловит его под SAVEPOINT и добавляет владельца), реестр — на случай вставки вне этого пути + "uq_consolidation_lock_version_active": ConstraintConflict( + "LOCKED_BY_OTHER", "Версию держит в работе другой пользователь" + ), "uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"), # Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан # миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а diff --git a/api/src/domain/policy.py b/api/src/domain/policy.py index f190904..0ab4690 100644 --- a/api/src/domain/policy.py +++ b/api/src/domain/policy.py @@ -7,6 +7,9 @@ записи»; объект вне видимости вызывающий превращает в 404 с тем же телом, что у несуществующего. ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.15). + +Редактируемость версии консолидации (Срез 4, spec directory-policy «Видимость консолидаций и редактируемость версии», +design D-8): `ensure_version_editable` — чистая проверка статуса и блокировки после права и видимости. """ import uuid @@ -15,7 +18,8 @@ from typing import TYPE_CHECKING, Any from sqlalchemy import Select, and_, or_, select -from src.common.exceptions import DomainValidationError +from src.common.exceptions import ConflictError, DomainValidationError +from src.models.consolidation import ConsolidationLock, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity from src.models.enums import ConsolidationStatus @@ -24,7 +28,17 @@ if TYPE_CHECKING: from src.repositories.users import UserWithAccess -# Статусы версии консолидации вне «активных» (фильтр `in_work_only` списка периодов, spec consolidations-api) +# Статус «в работе» (§9.1, spec consolidations-api «Статусы Среза 4»): только в нём версия редактируется +IN_WORK_STATUSES = frozenset( + { + ConsolidationStatus.INITIATED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, + } +) + +# Статусы версии консолидации вне «активных» (фильтр `in_work_only` списка периодов, spec consolidations-api). +# Не дополнение `IN_WORK_STATUSES`: версия на маршруте согласования (Срез 7) не «в работе», но и не завершена FINISHED_STATUSES = frozenset( { ConsolidationStatus.VERIFIED, @@ -119,3 +133,27 @@ def office_for_create(scope: VisibilityScope, requested: uuid.UUID | None) -> uu if requested is None or requested == scope.office_id: return scope.office_id raise DomainValidationError("OFFICE_NOT_ALLOWED", "ГК можно закрепить только за своим филиалом") + + +def ensure_version_editable( + version: ConsolidationVersion, active_lock: ConsolidationLock | None, user_id: uuid.UUID +) -> None: + """Можно ли пользователю `user_id` править версию (периметр, ручной статус, сопоставление контрагента; D-8). + + Проверки по порядку: статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) → активная блокировка есть (иначе 409 + `LOCK_REQUIRED`) → она принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с `locked_by`, `locked_at`). + Право и видимость проверяет вызывающий до этого; `version` и `active_lock` он читает под `FOR UPDATE` версии + (D-9), чтобы перехват или аннулирование не проскочили между проверкой и правкой. Код роли не читается. + ASSUMPTION(OQ-119): у блокировки нет срока действия. + """ + if version.status not in IN_WORK_STATUSES: + raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется") + if active_lock is None: + raise ConflictError("LOCK_REQUIRED", "Сначала возьмите версию в работу") + if active_lock.locked_by != user_id: + raise ConflictError( + "LOCKED_BY_OTHER", + "Версию держит в работе другой пользователь", + locked_by=active_lock.locked_by, + locked_at=active_lock.locked_at, + ) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 39109f0..9cee00a 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -25,6 +25,7 @@ YEAR_MIN = 2000 YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 +MAX_TAKEOVER_REASON = 1000 # признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] # статус ячейки чек-листа (spec consolidations-api «Чек-лист комплекта») @@ -58,9 +59,12 @@ class LockOut(BaseModel): class LockIn(BaseModel): - """Тело `POST …/lock`: причина перехвата чужой блокировки (при взятии свободной версии не нужна).""" + """Тело `POST …/lock`: причина перехвата чужой блокировки (при взятии свободной версии не нужна). - takeover_reason: str | None = None + Пустая или из одних пробелов — как отсутствующая (чужая блокировка → 409 `LOCKED_BY_OTHER`). + """ + + takeover_reason: str | None = Field(default=None, max_length=MAX_TAKEOVER_REASON) class VersionBrief(BaseModel): diff --git a/api/src/services/consolidation_locks.py b/api/src/services/consolidation_locks.py new file mode 100644 index 0000000..c834407 --- /dev/null +++ b/api/src/services/consolidation_locks.py @@ -0,0 +1,191 @@ +"""Блокировка версии консолидации «взять в работу»: взятие, перехват, снятие (spec consolidations-api «Взять в работу, +перехват, снятие»; audit-trail `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`; design D-7, D-9). + +Каждая операция начинается с `consolidation_version FOR UPDATE` (D-9): правка версии владельцем держит ту же строку, +поэтому перехват ждёт конца правки и видит её зафиксированной, а два одновременных взятия выстраиваются в очередь +(второе после commit первого видит чужую блокировку). Частичный UNIQUE `uq_consolidation_lock_version_active` — +последний рубеж: вставка идёт под SAVEPOINT, нарушение превращается в 409 `LOCKED_BY_OTHER` без отравления транзакции +запроса. ASSUMPTION(OQ-119): блокировка не истекает по времени. +""" + +import datetime +import uuid + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.constraint_conflicts import constraint_name, resolve_unique_conflict +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.policy import VisibilityScope +from src.models.access import AppUser +from src.models.consolidation import ConsolidationLock, ConsolidationVersion +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus +from src.repositories import consolidations as repo +from src.repositories.users import UserWithAccess +from src.schemas.consolidations import LockIn, LockOut +from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND + +ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active" + + +async def release_active_lock( + session: AsyncSession, version_id: uuid.UUID, now: datetime.datetime +) -> ConsolidationLock | None: + """Снимает активную блокировку версии (`released_at = now`), если она есть; возвращает снятую или `None`. + + Аудит не пишет: вызывающий записывает своё событие (`CONSOLIDATION_LOCK`, перехват, `CANCELLED`). Версия должна быть + под `FOR UPDATE` вызывающего. + """ + lock = await repo.active_lock(session, version_id) + if lock is None: + return None + lock.released_at = now + await session.flush() + return lock + + +async def _user_name(session: AsyncSession, user_id: uuid.UUID) -> str: + user = await session.get(AppUser, user_id) + return user.full_name if user is not None else "" + + +def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: + return LockOut( + locked_by=lock.locked_by, + locked_by_name=locked_by_name, + locked_at=lock.locked_at, + taken_over_from=lock.taken_over_from, + takeover_reason=lock.takeover_reason, + ) + + +class ConsolidationLockService: + """Взятие версии в работу от имени текущего пользователя в его области видимости.""" + + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, principal: UserWithAccess + ) -> None: + self._session = session + self._audit = audit + self._scope = scope + self._principal = principal + + async def take(self, version_id: uuid.UUID, data: LockIn) -> tuple[LockOut, bool]: + """Взять версию в работу; `(блокировка, создана ли новая)` — 201 при `True`, 200 при своей активной. + + Порядок: `FOR UPDATE` версии → видимость (404) → `CANCELLED` → 409 `VERSION_NOT_EDITABLE` → активная + блокировка: нет — новая; своя — возвращается как есть; чужая без причины — 409 `LOCKED_BY_OTHER`; чужая с + непустой причиной — прежняя снимается, новая с `taken_over_from`/`takeover_reason`, аудит перехвата. + Причина при свободной версии не сохраняется (перехватывать не у кого). + """ + version = await self._locked_visible_version(version_id) + if version.status == ConsolidationStatus.CANCELLED: + raise ConflictError("VERSION_NOT_EDITABLE", "Аннулированную версию нельзя взять в работу") + user = self._principal.user + current = await repo.active_lock(self._session, version.id) + if current is not None and current.locked_by == user.id: + return _lock_out(current, user.full_name), False + reason = (data.takeover_reason or "").strip() or None + if current is not None and reason is None: + raise await self._locked_by_other(current) + + now = utc_now() + if current is None: + lock = await self._insert(version.id, now) + await self._audit.record( + action=AuditAction.CONSOLIDATION_LOCK, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + after_value={"locked_by": user.id, "released": False}, + ) + return _lock_out(lock, user.full_name), True + + previous_owner = current.locked_by + await release_active_lock(self._session, version.id, now) + lock = await self._insert(version.id, now, taken_over_from=previous_owner, takeover_reason=reason) + await self._audit.record( + action=AuditAction.CONSOLIDATION_LOCK_TAKEOVER, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + before_value={"locked_by": previous_owner}, + after_value={"locked_by": user.id}, + comment=reason, + ) + return _lock_out(lock, user.full_name), True + + async def release(self, version_id: uuid.UUID) -> None: + """Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409 + `NOT_LOCK_OWNER`; активной нет — ничего не делает (идемпотентно).""" + version = await self._locked_visible_version(version_id) + current = await repo.active_lock(self._session, version.id) + if current is None: + return + user = self._principal.user + if current.locked_by != user.id: + raise ConflictError( + "NOT_LOCK_OWNER", + "Снять можно только свою блокировку", + locked_by=current.locked_by, + locked_at=current.locked_at, + ) + await release_active_lock(self._session, version.id, utc_now()) + await self._audit.record( + action=AuditAction.CONSOLIDATION_LOCK, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + after_value={"locked_by": user.id, "released": True}, + ) + + async def _locked_visible_version(self, version_id: uuid.UUID) -> ConsolidationVersion: + """Версия под `FOR UPDATE` (D-9), если её ГК видна; иначе 404 — одинаковый для чужой и несуществующей.""" + version = await repo.lock_version(self._session, version_id) + if version is None or await repo.get_version(self._session, self._scope, version_id) is None: + raise NotFoundError(VERSION_NOT_FOUND) + return version + + async def _insert( + self, + version_id: uuid.UUID, + now: datetime.datetime, + *, + taken_over_from: uuid.UUID | None = None, + takeover_reason: str | None = None, + ) -> ConsolidationLock: + """Новая активная блокировка под SAVEPOINT: нарушение частичного UNIQUE → 409 `LOCKED_BY_OTHER` с владельцем.""" + lock = ConsolidationLock( + consolidation_version_id=version_id, + locked_by=self._principal.user.id, + locked_at=now, + taken_over_from=taken_over_from, + takeover_reason=takeover_reason, + ) + try: + async with self._session.begin_nested(): + self._session.add(lock) + await self._session.flush() + except IntegrityError as exc: + conflict = resolve_unique_conflict(exc) + if conflict is None or constraint_name(exc) != ACTIVE_LOCK_INDEX: + raise + winner = await repo.active_lock(self._session, version_id) + if winner is None: # победитель уже снял блокировку — постоянная часть ответа без владельца + raise ConflictError(conflict.code, conflict.message) from exc + raise await self._locked_by_other(winner) from exc + return lock + + async def _locked_by_other(self, lock: ConsolidationLock) -> ConflictError: + return ConflictError( + "LOCKED_BY_OTHER", + "Версию держит в работе другой пользователь", + locked_by=lock.locked_by, + locked_by_name=await _user_name(self._session, lock.locked_by), + locked_at=lock.locked_at, + ) diff --git a/api/src/services/consolidation_status.py b/api/src/services/consolidation_status.py new file mode 100644 index 0000000..97e9a0a --- /dev/null +++ b/api/src/services/consolidation_status.py @@ -0,0 +1,140 @@ +"""Статусы версии консолидации Среза 4 (§9.1; spec consolidations-api «Статусы Среза 4»; audit-trail +`CONSOLIDATION_STATUS_CHANGE`; design D-6, D-8). + +`transition` — ЕДИНСТВЕННОЕ место, где меняется `consolidation_version.status`: ручной `POST …/status` и автоматические +переходы (загрузка, маппинг, подтверждение — Task 6–7) зовут её же, поэтому история и аудит пишутся всегда одинаково. +Ручные переходы — только `MANUAL_TRANSITIONS`; остальное — 409 `TRANSITION_NOT_ALLOWED`. +""" + +import datetime +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.policy import VisibilityScope, ensure_version_editable +from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus +from src.repositories import consolidations as repo +from src.repositories.users import UserWithAccess +from src.schemas.consolidations import StatusIn, VersionCard +from src.services import consolidation_locks +from src.services.audit import AuditService +from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, ConsolidationService + +_CANCELLED = ConsolidationStatus.CANCELLED +_INITIATED = ConsolidationStatus.INITIATED + +#: Ручные переходы Среза 4: из статуса «в работе» → куда можно (`CANCELLED` — из любого, возврат в `INITIATED` — из +#: `DOCUMENTS_PROCESSING` / `DATA_CORRECTION`). Переходы Среза 7 (`CONSOLIDATION_RF` и далее) сюда не входят. +MANUAL_TRANSITIONS: dict[ConsolidationStatus, frozenset[ConsolidationStatus]] = { + ConsolidationStatus.INITIATED: frozenset({_CANCELLED}), + ConsolidationStatus.DOCUMENTS_PROCESSING: frozenset({_CANCELLED, _INITIATED}), + ConsolidationStatus.DATA_CORRECTION: frozenset({_CANCELLED, _INITIATED}), +} + + +async def transition( # noqa: PLR0913 - контекст аудита (пользователь, IP, user-agent) и время — от вызывающего + session: AsyncSession, + audit: AuditService, + *, + version: ConsolidationVersion, + to: ConsolidationStatus, + changed_by: uuid.UUID, + comment: str | None, + ip_address: str | None = None, + user_agent: str | None = None, + now: datetime.datetime, +) -> ConsolidationStatusHistory: + """Переводит версию в статус `to`: `status`, `updated_by/at`, запись `consolidation_status_history` и + `audit_event(CONSOLIDATION_STATUS_CHANGE, before = {status}, after = {status}, comment)` с `user_id = changed_by`. + + При `CANCELLED` дополнительно: `cancelled_reason = comment`, `is_in_work = false`, активная блокировка снимается + (`consolidation_locks.release_active_lock`, design решение №3). Допустимость перехода НЕ проверяется — это дело + вызывающего (ручной — `MANUAL_TRANSITIONS`, автоматические — свои условия, D-6). + + Версия ДОЛЖНА быть уже заблокирована вызывающим `FOR UPDATE` (`repo.lock_version` / + `repo.lock_versions_for_entity`, D-9): иначе параллельный переход или перехват прочтёт устаревший статус. + """ + from_status = version.status + version.status = to + version.updated_by = changed_by + version.updated_at = now + if to == ConsolidationStatus.CANCELLED: + version.cancelled_reason = comment + version.is_in_work = False + await consolidation_locks.release_active_lock(session, version.id, now) + record = ConsolidationStatusHistory( + version_id=version.id, + from_status=from_status, + to_status=to, + changed_by=changed_by, + changed_at=now, + comment=comment, + ) + session.add(record) + await session.flush() + await audit.record( + action=AuditAction.CONSOLIDATION_STATUS_CHANGE, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + user_id=changed_by, + consolidation_version_id=version.id, + before_value={"status": from_status}, + after_value={"status": to}, + comment=comment, + ip_address=ip_address, + user_agent=user_agent, + ) + return record + + +class ConsolidationStatusService: + """Ручная смена статуса версии от имени текущего пользователя (владельца блокировки).""" + + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, principal: UserWithAccess + ) -> None: + self._session = session + self._audit = audit + self._scope = scope + self._principal = principal + + async def change(self, version_id: uuid.UUID, data: StatusIn) -> VersionCard: + """Ручной переход; ответ — карточка версии. + + Порядок: `FOR UPDATE` версии (D-9) → видимость (404) → `ensure_version_editable` (409 `VERSION_NOT_EDITABLE` + / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`) → переход из `MANUAL_TRANSITIONS` (иначе 409 `TRANSITION_NOT_ALLOWED` + с `from_status`, `to_status`; повтор того же статуса — тоже) → `CANCELLED` без комментария — 422 + `COMMENT_REQUIRED`. Комментарий из одних пробелов считается пустым. + """ + version = await repo.lock_version(self._session, version_id) + if version is None or await repo.get_version(self._session, self._scope, version_id) is None: + raise NotFoundError(VERSION_NOT_FOUND) + user = self._principal.user + ensure_version_editable(version, await repo.active_lock(self._session, version.id), user.id) + if data.to_status not in MANUAL_TRANSITIONS.get(version.status, frozenset()): + raise ConflictError( + "TRANSITION_NOT_ALLOWED", + f"Переход {version.status.value} → {data.to_status.value} вручную недоступен", + from_status=version.status, + to_status=data.to_status, + ) + comment = (data.comment or "").strip() or None + if data.to_status == ConsolidationStatus.CANCELLED and comment is None: + raise DomainValidationError("COMMENT_REQUIRED", "Укажите причину аннулирования") + await transition( + self._session, + self._audit.service, + version=version, + to=data.to_status, + changed_by=user.id, + comment=comment, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=utc_now(), + ) + return await ConsolidationService(self._session, self._audit, self._scope, self._principal).card(version.id) diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 4ec8a7e..759f99e 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -52,7 +52,6 @@ from src.services import consolidation_documents from src.services.company_groups import COMPANY_GROUP_NOT_FOUND from src.services.related_groups import RELATED_GROUP_NOT_FOUND -CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей (spec directory-policy) VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей OBJECT_TYPE = "consolidation_version" MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) diff --git a/api/tests/factories.py b/api/tests/factories.py index 1a3504d..492a925 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult from src.domain.consolidation_periods import reporting_date from src.domain.osv_mapping import build_entries, document_fields +from src.domain.policy import FINISHED_STATUSES from src.models.access import AppUser, Role, UserRole from src.models.accounting import ChartOfAccount from src.models.consolidation import ( @@ -73,17 +74,9 @@ from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) FACTORY_BUCKET = "arm-ka" + + # статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию -FINISHED_STATUSES = frozenset( - { - ConsolidationStatus.VERIFIED, - ConsolidationStatus.CANCELLED, - ConsolidationStatus.ARCHIVED, - ConsolidationStatus.REJECTED, - } -) - - def _now() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) diff --git a/api/tests/test_api_consolidation_locks.py b/api/tests/test_api_consolidation_locks.py new file mode 100644 index 0000000..8e54b93 --- /dev/null +++ b/api/tests/test_api_consolidation_locks.py @@ -0,0 +1,469 @@ +"""Блокировка версии консолидации: взять в работу, перехват, снятие (spec consolidations-api «Взять в работу, +перехват, снятие»; audit-trail `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`; design D-7, D-9; Review Focus 4). + +Гонки — на `committed_*` (настоящие параллельные транзакции): два одновременных `POST …/lock` и перехват во время +правки версии владельцем (правка держит `consolidation_version FOR UPDATE`, перехват ждёт её commit). +""" + +import asyncio +import datetime +import uuid +from typing import Any + +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationMember, ConsolidationVersion +from src.models.directories import CompanyGroup, RegionalOffice +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter +from src.repositories import consolidations as repo +from src.services import consolidation_status +from src.services.audit import AuditService +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +KA_RF = "ka_rf@armka.local" # «А» +RKA = "rka@armka.local" # «Б», тот же филиал +EXPERT = "expert@armka.local" +VERSIONS = "/consolidations/versions" +LOCK_WAIT_SECONDS = 5 +MAX_REASON = 1000 + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _version( + session: AsyncSession, + *, + office_name: str = ROSTOV, + status: ConsolidationStatus = ConsolidationStatus.INITIATED, +) -> ConsolidationVersion: + """Версия консолидации ГК филиала `office_name` за 2024 Q3 из двух ЮЛ (инициатор — ka_rf).""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)] + _, version = await make_consolidation( + session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=entities, + initiator=await _user(session, KA_RF), + status=status, + ) + return version + + +def _lock_url(version_id: uuid.UUID) -> str: + return f"{VERSIONS}/{version_id}/lock" + + +async def _locks(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationLock]: + stmt = ( + select(ConsolidationLock) + .where(ConsolidationLock.consolidation_version_id == version_id) + .order_by(ConsolidationLock.locked_at) + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _events(session: AsyncSession, action: AuditAction) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id) + return list((await session.execute(stmt)).scalars()) + + +# --- взять в работу ------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_take_free_version_creates_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + user = await _user(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await client.post(_lock_url(version.id)) + + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert (body["locked_by"], body["locked_by_name"]) == (str(user.id), user.full_name) + assert (body["taken_over_from"], body["takeover_reason"]) == (None, None) + [lock] = await _locks(seeded, version.id) + assert (lock.locked_by, lock.released_at) == (user.id, None) + assert datetime.datetime.fromisoformat(body["locked_at"]) == lock.locked_at + [event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK) + assert (event.section, event.object_type, event.object_id) == ( + AuditSection.CONSOLIDATION, + "consolidation_version", + version.id, + ) + assert (event.consolidation_version_id, event.user_id) == (version.id, user.id) + assert event.before_value is None + assert event.after_value == {"locked_by": str(user.id), "released": False} + card = (await client.get(f"{VERSIONS}/{version.id}")).json() + assert card["lock"]["locked_by"] == str(user.id) + + +@pytest.mark.integration +async def test_take_own_lock_returns_it_unchanged(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + client = await auth_client(KA_RF) + first = await client.post(_lock_url(version.id)) + + again = await client.post(_lock_url(version.id), json={"takeover_reason": "повтор"}) + + assert again.status_code == HTTP_OK, again.text + assert again.json() == first.json() + assert len(await _locks(seeded, version.id)) == 1 + assert len(await _events(seeded, AuditAction.CONSOLIDATION_LOCK)) == 1 + assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK_TAKEOVER) == [] + + +@pytest.mark.integration +@pytest.mark.parametrize("payload", [None, {}, {"takeover_reason": None}, {"takeover_reason": " "}]) +async def test_take_foreign_lock_without_reason_is_conflict( + auth_client: AuthClientFactory, seeded: AsyncSession, payload: dict[str, Any] | None +) -> None: + version = await _version(seeded) + owner = await _user(seeded, KA_RF) + lock = await make_lock(seeded, version=version, user=owner) + client = await auth_client(RKA) + + response = await client.post(_lock_url(version.id), json=payload) + + assert response.status_code == HTTP_CONFLICT, response.text + detail = response.json()["detail"] + assert detail["code"] == "LOCKED_BY_OTHER" + assert (detail["locked_by"], detail["locked_by_name"]) == (str(owner.id), owner.full_name) + assert datetime.datetime.fromisoformat(detail["locked_at"]) == lock.locked_at + [still] = await _locks(seeded, version.id) + assert still.released_at is None + + +@pytest.mark.integration +async def test_lock_does_not_expire(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """ASSUMPTION(OQ-119): блокировка двухлетней давности по-прежнему чужая.""" + version = await _version(seeded) + lock = await make_lock(seeded, version=version, user=await _user(seeded, KA_RF)) + lock.locked_at = lock.locked_at - datetime.timedelta(days=730) + await seeded.flush() + client = await auth_client(RKA) + + response = await client.post(_lock_url(version.id)) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCKED_BY_OTHER" + + +@pytest.mark.integration +async def test_takeover_with_reason(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + owner = await _user(seeded, KA_RF) + taker = await _user(seeded, RKA) + await make_lock(seeded, version=version, user=owner) + client = await auth_client(RKA) + + response = await client.post(_lock_url(version.id), json={"takeover_reason": "Отпуск"}) + + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert (body["locked_by"], body["locked_by_name"]) == (str(taker.id), taker.full_name) + assert (body["taken_over_from"], body["takeover_reason"]) == (str(owner.id), "Отпуск") + old, new = await _locks(seeded, version.id) + assert old.locked_by == owner.id + assert old.released_at is not None + assert (new.locked_by, new.released_at, new.taken_over_from, new.takeover_reason) == ( + taker.id, + None, + owner.id, + "Отпуск", + ) + assert old.released_at == new.locked_at # одно `now` на операцию + [event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK_TAKEOVER) + assert (event.section, event.object_type, event.object_id, event.consolidation_version_id) == ( + AuditSection.CONSOLIDATION, + "consolidation_version", + version.id, + version.id, + ) + assert event.user_id == taker.id + assert event.before_value == {"locked_by": str(owner.id)} + assert event.after_value == {"locked_by": str(taker.id)} + assert event.comment == "Отпуск" + assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == [] + + +@pytest.mark.integration +async def test_takeover_reason_longer_than_limit_is_rejected( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + version = await _version(seeded) + await make_lock(seeded, version=version, user=await _user(seeded, KA_RF)) + client = await auth_client(RKA) + + too_long = await client.post(_lock_url(version.id), json={"takeover_reason": "я" * (MAX_REASON + 1)}) + at_limit = await client.post(_lock_url(version.id), json={"takeover_reason": "я" * MAX_REASON}) + + assert too_long.status_code == HTTP_UNPROCESSABLE + assert at_limit.status_code == HTTP_CREATED, at_limit.text + + +@pytest.mark.integration +async def test_take_cancelled_version_is_not_editable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + response = await client.post(_lock_url(version.id)) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert await _locks(seeded, version.id) == [] + + +@pytest.mark.integration +async def test_take_version_outside_in_work_but_not_cancelled_is_allowed( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """«В любом статусе, кроме CANCELLED»: версию на маршруте согласования (Срез 7) можно взять в работу.""" + version = await _version(seeded, status=ConsolidationStatus.CONSOLIDATION_RF) + client = await auth_client(KA_RF) + + response = await client.post(_lock_url(version.id)) + + assert response.status_code == HTTP_CREATED, response.text + + +@pytest.mark.integration +async def test_lock_routes_hide_foreign_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded, office_name=KRASNODAR) + client = await auth_client(KA_RF) + + for method in ("post", "delete"): + foreign = await client.request(method, _lock_url(version.id)) + missing = await client.request(method, _lock_url(uuid.uuid4())) + assert foreign.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + assert await _locks(seeded, version.id) == [] + + +@pytest.mark.integration +async def test_lock_routes_require_consolidation_edit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + client = await auth_client(EXPERT) + + assert (await client.post(_lock_url(version.id))).status_code == HTTP_FORBIDDEN + assert (await client.delete(_lock_url(version.id))).status_code == HTTP_FORBIDDEN + + +# --- снятие -------------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_release_own_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + user = await _user(seeded, KA_RF) + await make_lock(seeded, version=version, user=user) + client = await auth_client(KA_RF) + + response = await client.delete(_lock_url(version.id)) + + assert response.status_code == HTTP_NO_CONTENT, response.text + assert response.content == b"" + [lock] = await _locks(seeded, version.id) + assert lock.released_at is not None + [event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK) + assert (event.object_id, event.consolidation_version_id, event.user_id) == (version.id, version.id, user.id) + assert event.after_value == {"locked_by": str(user.id), "released": True} + assert (await client.get(f"{VERSIONS}/{version.id}")).json()["lock"] is None + + +@pytest.mark.integration +async def test_release_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + owner = await _user(seeded, KA_RF) + await make_lock(seeded, version=version, user=owner) + client = await auth_client(RKA) + + response = await client.delete(_lock_url(version.id)) + + assert response.status_code == HTTP_CONFLICT + detail = response.json()["detail"] + assert (detail["code"], detail["locked_by"]) == ("NOT_LOCK_OWNER", str(owner.id)) + [lock] = await _locks(seeded, version.id) + assert lock.released_at is None + assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == [] + + +@pytest.mark.integration +async def test_release_without_active_lock_is_noop(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version = await _version(seeded) + await make_lock(seeded, version=version, user=await _user(seeded, KA_RF), released=True) + client = await auth_client(KA_RF) + + response = await client.delete(_lock_url(version.id)) + + assert response.status_code == HTTP_NO_CONTENT + assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == [] + + +# --- гонки (настоящие параллельные транзакции) --------------------------------------------------------------------- + + +async def _committed_version(maker: async_sessionmaker[AsyncSession]) -> uuid.UUID: + async with maker() as session, session.begin(): + return (await _version(session)).id + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + """Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки строки.""" + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("запрос не встал в ожидание блокировки версии") + + +@pytest.mark.integration +async def test_two_simultaneous_takes_one_wins( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Два `POST …/lock` свободной версии одновременно: у одного 201, у другого 409 `LOCKED_BY_OTHER`.""" + version_id = await _committed_version(committed_sessionmaker) + first, second = await committed_client(KA_RF), await committed_client(RKA) + + responses = await asyncio.wait_for( + asyncio.gather(first.post(_lock_url(version_id)), second.post(_lock_url(version_id))), LOCK_WAIT_SECONDS + ) + + assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_CONFLICT] + winner = next(response for response in responses if response.status_code == HTTP_CREATED) + loser = next(response for response in responses if response.status_code == HTTP_CONFLICT) + assert loser.json()["detail"]["code"] == "LOCKED_BY_OTHER" + assert loser.json()["detail"]["locked_by"] == winner.json()["locked_by"] + async with committed_sessionmaker() as session: + active = ( + await session.execute( + select(func.count()) + .select_from(ConsolidationLock) + .where( + ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None) + ) + ) + ).scalar_one() + assert active == 1 + + +@pytest.mark.integration +async def test_takeover_waits_for_owner_edit_then_owner_loses_lock( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Review Focus 4: владелец А правит периметр (`consolidation_version FOR UPDATE`), Б в это время перехватывает. + + Перехват ждёт транзакцию А; после её commit правка А зафиксирована, Б — владелец, следующая правка А — + 409 `LOCKED_BY_OTHER`. + """ + async with committed_sessionmaker() as setup, setup.begin(): + version = await _version(setup) + owner = await _user(setup, KA_RF) + await make_lock(setup, version=version, user=owner) + consolidation = await setup.get(Consolidation, version.consolidation_id) + assert consolidation is not None + group = await setup.get(CompanyGroup, consolidation.company_group_id) + assert group is not None + newcomer_id = (await make_legal_entity(setup, name="ООО Новый член", group=group)).id + version_id = version.id + taker_client = await committed_client(RKA) + owner_client = await committed_client(KA_RF) + + async with committed_sessionmaker() as editor, editor.begin(): + # правка периметра владельцем: версия под FOR UPDATE первой (D-9), затем строки версии + locked = await repo.lock_version(editor, version_id) + assert locked is not None + editor.add(ConsolidationMember(consolidation_version_id=version_id, legal_entity_id=newcomer_id)) + await editor.flush() + takeover = asyncio.create_task(taker_client.post(_lock_url(version_id), json={"takeover_reason": "Отпуск"})) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not takeover.done() + response = await asyncio.wait_for(takeover, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CREATED, response.text + assert response.json()["taken_over_from"] == str(owner.id) + async with committed_sessionmaker() as session: + members = ( + await session.execute( + select(ConsolidationMember.legal_entity_id).where( + ConsolidationMember.consolidation_version_id == version_id + ) + ) + ).scalars() + assert newcomer_id in set(members) # правка владельца зафиксирована до перехвата + next_edit = await owner_client.post( + f"{VERSIONS}/{version_id}/status", json={"to_status": "CANCELLED", "comment": "x"} + ) + assert next_edit.status_code == HTTP_CONFLICT, next_edit.text + assert next_edit.json()["detail"]["code"] == "LOCKED_BY_OTHER" + assert next_edit.json()["detail"]["locked_by"] == response.json()["locked_by"] + + +@pytest.mark.integration +async def test_takeover_during_cancellation_sees_cancelled_version( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Перехват читает статус и блокировку только ПОСЛЕ `FOR UPDATE` версии (D-9). + + Владелец аннулирует версию (транзакция держит версию и снимает свою блокировку); перехват, пришедший в это время, + ждёт и после commit видит `CANCELLED` → 409 `VERSION_NOT_EDITABLE`. Прочитай он состояние до ожидания — + перехватил бы аннулированную версию (вставку блокировки задержал бы лишь FK, а не проверка статуса). + """ + async with committed_sessionmaker() as setup, setup.begin(): + version = await _version(setup) + owner = await _user(setup, KA_RF) + await make_lock(setup, version=version, user=owner) + version_id, owner_id = version.id, owner.id + taker_client = await committed_client(RKA) + + async with committed_sessionmaker() as editor, editor.begin(): + locked = await repo.lock_version(editor, version_id) + assert locked is not None + await consolidation_status.transition( + editor, + AuditService(editor), + version=locked, + to=ConsolidationStatus.CANCELLED, + changed_by=owner_id, + comment="Дубль", + now=datetime.datetime.now(datetime.UTC), + ) + takeover = asyncio.create_task(taker_client.post(_lock_url(version_id), json={"takeover_reason": "Отпуск"})) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not takeover.done() + response = await asyncio.wait_for(takeover, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + async with committed_sessionmaker() as session: + assert all(lock.released_at is not None for lock in await _locks(session, version_id)) diff --git a/api/tests/test_api_consolidation_status.py b/api/tests/test_api_consolidation_status.py new file mode 100644 index 0000000..028841b --- /dev/null +++ b/api/tests/test_api_consolidation_status.py @@ -0,0 +1,445 @@ +"""Ручные статусы версии консолидации Среза 4 и общий переход `transition` (spec consolidations-api «Статусы Среза 4»; +directory-policy «Видимость консолидаций и редактируемость версии»; audit-trail `CONSOLIDATION_STATUS_CHANGE`; +design D-6, D-8).""" + +import datetime +import uuid +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ( + ConsolidationDocument, + ConsolidationLock, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter +from src.services import consolidation_locks, consolidation_status +from src.services.audit import AuditService +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity, make_lock + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" +CURATOR = "curator_go@armka.local" +EXPERT = "expert@armka.local" +VERSIONS = "/consolidations/versions" +IN_WORK = ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, +) + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _version( + session: AsyncSession, + *, + status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION, + office_name: str = ROSTOV, + locked_by: str | None = KA_RF, +) -> tuple[ConsolidationVersion, list[LegalEntity]]: + """Версия ГК филиала за 2024 Q3 из двух ЮЛ в статусе `status`; `locked_by` — владелец активной блокировки.""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)] + _, version = await make_consolidation( + session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=entities, + initiator=await _user(session, KA_RF), + status=status, + ) + if locked_by is not None: + await make_lock(session, version=version, user=await _user(session, locked_by)) + return version, entities + + +def _status_url(version_id: uuid.UUID) -> str: + return f"{VERSIONS}/{version_id}/status" + + +async def _history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]: + stmt = ( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version_id) + .order_by(ConsolidationStatusHistory.changed_at, ConsolidationStatusHistory.id) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _status_events(session: AsyncSession) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE) + return list((await session.execute(stmt)).scalars()) + + +async def _reload(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationVersion: + stmt = ( + select(ConsolidationVersion) + .where(ConsolidationVersion.id == version_id) + .execution_options(populate_existing=True) + ) + return (await session.execute(stmt)).scalar_one() + + +async def _active_locks(session: AsyncSession, version_id: uuid.UUID) -> int: + stmt = ( + select(func.count()) + .select_from(ConsolidationLock) + .where(ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None)) + ) + return (await session.execute(stmt)).scalar_one() + + +# --- аннулирование ------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _version(seeded, status=ConsolidationStatus.DATA_CORRECTION) + user = await _user(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Дубль"}) + + assert response.status_code == HTTP_OK, response.text + card = response.json() + assert (card["status"], card["is_in_work"], card["lock"]) == ("CANCELLED", False, None) + assert card["history"][-1]["from_status"] == "DATA_CORRECTION" + assert (card["history"][-1]["to_status"], card["history"][-1]["comment"]) == ("CANCELLED", "Дубль") + stored = await _reload(seeded, version.id) + assert (stored.status, stored.cancelled_reason, stored.is_in_work) == ( + ConsolidationStatus.CANCELLED, + "Дубль", + False, + ) + assert stored.updated_by == user.id + assert stored.updated_at is not None + assert await _active_locks(seeded, version.id) == 0 + last = (await _history(seeded, version.id))[-1] + assert (last.from_status, last.to_status, last.changed_by, last.comment) == ( + ConsolidationStatus.DATA_CORRECTION, + ConsolidationStatus.CANCELLED, + user.id, + "Дубль", + ) + assert last.changed_at == stored.updated_at + [event] = await _status_events(seeded) + assert (event.section, event.object_type, event.object_id, event.consolidation_version_id) == ( + AuditSection.CONSOLIDATION, + "consolidation_version", + version.id, + version.id, + ) + assert event.user_id == user.id + assert (event.before_value, event.after_value, event.comment) == ( + {"status": "DATA_CORRECTION"}, + {"status": "CANCELLED"}, + "Дубль", + ) + assert event.ip_address is not None or event.user_agent is not None # контекст запроса передан в аудит + + +@pytest.mark.integration +@pytest.mark.parametrize("status", IN_WORK) +async def test_cancel_allowed_from_every_in_work_status( + auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus +) -> None: + version, _ = await _version(seeded, status=status) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Ошибка"}) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["status"] == "CANCELLED" + + +@pytest.mark.integration +@pytest.mark.parametrize("payload", [{"to_status": "CANCELLED"}, {"to_status": "CANCELLED", "comment": " "}]) +async def test_cancel_without_comment( + auth_client: AuthClientFactory, seeded: AsyncSession, payload: dict[str, Any] +) -> None: + version, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json=payload) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "COMMENT_REQUIRED" + assert (await _reload(seeded, version.id)).status == ConsolidationStatus.DATA_CORRECTION + assert await _active_locks(seeded, version.id) == 1 + + +# --- возврат в INITIATED ------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION]) +async def test_return_to_initiated_keeps_bound_documents( + auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus +) -> None: + version, entities = await _version(seeded, status=status) + document = await make_confirmed_osv( + seeded, entity=entities[0], account="60", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[] + ) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"}) + + assert response.status_code == HTTP_OK, response.text + card = response.json() + assert (card["status"], card["is_in_work"]) == ("INITIATED", True) + assert card["lock"]["locked_by"] == str((await _user(seeded, KA_RF)).id) # блокировка остаётся + last = (await _history(seeded, version.id))[-1] + assert (last.from_status, last.to_status, last.comment) == (status, ConsolidationStatus.INITIATED, None) + bound = ( + await seeded.execute( + select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version.id + ) + ) + ).scalars() + assert list(bound) == [document.id] + [event] = await _status_events(seeded) + assert (event.before_value, event.after_value, event.comment) == ( + {"status": status.value}, + {"status": "INITIATED"}, + None, + ) + + +# --- запрещённые переходы ------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("status", "to_status"), + [ + (ConsolidationStatus.DATA_CORRECTION, "CONSOLIDATION_RF"), # переход Среза 7 + (ConsolidationStatus.INITIATED, "INITIATED"), # повтор статуса + (ConsolidationStatus.DATA_CORRECTION, "DATA_CORRECTION"), + (ConsolidationStatus.INITIATED, "DOCUMENTS_PROCESSING"), # автоматический, не ручной + (ConsolidationStatus.DOCUMENTS_PROCESSING, "DATA_CORRECTION"), + (ConsolidationStatus.DATA_CORRECTION, "VERIFIED"), + ], +) +async def test_transition_not_allowed( + auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus, to_status: str +) -> None: + version, _ = await _version(seeded, status=status) + history_before = len(await _history(seeded, version.id)) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": to_status, "comment": "попытка"}) + + assert response.status_code == HTTP_CONFLICT, response.text + detail = response.json()["detail"] + assert (detail["code"], detail["from_status"], detail["to_status"]) == ( + "TRANSITION_NOT_ALLOWED", + status.value, + to_status, + ) + assert (await _reload(seeded, version.id)).status == status + assert len(await _history(seeded, version.id)) == history_before + assert await _status_events(seeded) == [] + + +# --- политика редактируемости на ручной смене статуса -------------------------------------------------------------- + + +@pytest.mark.integration +async def test_status_requires_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _version(seeded, locked_by=None) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"}) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCK_REQUIRED" + + +@pytest.mark.integration +async def test_status_with_foreign_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _version(seeded, locked_by=RKA) + owner = await _user(seeded, RKA) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"}) + + assert response.status_code == HTTP_CONFLICT + detail = response.json()["detail"] + assert (detail["code"], detail["locked_by"]) == ("LOCKED_BY_OTHER", str(owner.id)) + assert "locked_at" in detail + + +@pytest.mark.integration +async def test_status_of_cancelled_version_is_not_editable( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Аннулированная версия: даже владелец прежней блокировки получает 409 `VERSION_NOT_EDITABLE`.""" + version, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"}) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + + +@pytest.mark.integration +async def test_status_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None) + client = await auth_client(KA_RF) + body = {"to_status": "CANCELLED", "comment": "x"} + + foreign = await client.post(_status_url(version.id), json=body) + missing = await client.post(_status_url(uuid.uuid4()), json=body) + + assert foreign.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + + +@pytest.mark.integration +@pytest.mark.parametrize("email", [CURATOR, EXPERT]) +async def test_status_requires_consolidation_edit( + auth_client: AuthClientFactory, seeded: AsyncSession, email: str +) -> None: + version, _ = await _version(seeded) + client = await auth_client(email) + + response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"}) + + assert response.status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_status_rejects_unknown_status(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.post(_status_url(version.id), json={"to_status": "DONE"}) + + assert response.status_code == HTTP_UNPROCESSABLE + + +# --- transition: единственное место смены статуса (автопереходы Task 6–7 зовут её же) ------------------------------ + + +def test_manual_transitions_table() -> None: + cancelled, initiated = ConsolidationStatus.CANCELLED, ConsolidationStatus.INITIATED + assert consolidation_status.MANUAL_TRANSITIONS == { + ConsolidationStatus.INITIATED: frozenset({cancelled}), + ConsolidationStatus.DOCUMENTS_PROCESSING: frozenset({cancelled, initiated}), + ConsolidationStatus.DATA_CORRECTION: frozenset({cancelled, initiated}), + } + + +@pytest.mark.integration +async def test_transition_to_cancelled_releases_active_lock( + seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED) + user = await _user(seeded, RKA) + now = datetime.datetime(2026, 10, 6, 12, 0, tzinfo=datetime.UTC) + calls: list[tuple[uuid.UUID, datetime.datetime]] = [] + original = consolidation_locks.release_active_lock + + async def spy(session: AsyncSession, version_id: uuid.UUID, at: datetime.datetime) -> ConsolidationLock | None: + calls.append((version_id, at)) + return await original(session, version_id, at) + + monkeypatch.setattr(consolidation_locks, "release_active_lock", spy) + + record = await consolidation_status.transition( + seeded, + AuditService(seeded), + version=version, + to=ConsolidationStatus.CANCELLED, + changed_by=user.id, + comment="Аннулирована", + ip_address="10.0.0.1", + user_agent="pytest", + now=now, + ) + + assert calls == [(version.id, now)] + assert await _active_locks(seeded, version.id) == 0 + released = ( + await seeded.execute(select(ConsolidationLock).where(ConsolidationLock.consolidation_version_id == version.id)) + ).scalar_one() + assert released.released_at == now + assert (record.from_status, record.to_status, record.changed_by, record.changed_at, record.comment) == ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.CANCELLED, + user.id, + now, + "Аннулирована", + ) + assert (version.status, version.is_in_work, version.cancelled_reason) == ( + ConsolidationStatus.CANCELLED, + False, + "Аннулирована", + ) + assert (version.updated_by, version.updated_at) == (user.id, now) + [event] = await _status_events(seeded) + assert (event.user_id, event.ip_address, event.user_agent) == (user.id, "10.0.0.1", "pytest") + + +@pytest.mark.integration +async def test_transition_other_than_cancelled_keeps_lock( + seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Автоматический переход (Task 6–7) и возврат в `INITIATED` блокировку не снимают; `user_id` — `changed_by`.""" + version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED) + confirmer = await _user(seeded, RKA) + calls: list[uuid.UUID] = [] + + async def spy(_session: AsyncSession, version_id: uuid.UUID, _at: datetime.datetime) -> None: + calls.append(version_id) + + monkeypatch.setattr(consolidation_locks, "release_active_lock", spy) + + await consolidation_status.transition( + seeded, + AuditService(seeded), + version=version, + to=ConsolidationStatus.DATA_CORRECTION, + changed_by=confirmer.id, + comment=None, + now=datetime.datetime.now(datetime.UTC), + ) + + assert calls == [] + assert await _active_locks(seeded, version.id) == 1 + assert (version.status, version.is_in_work, version.cancelled_reason) == ( + ConsolidationStatus.DATA_CORRECTION, + True, + None, + ) + [event] = await _status_events(seeded) + assert (event.user_id, event.ip_address, event.before_value, event.after_value) == ( + confirmer.id, + None, + {"status": "INITIATED"}, + {"status": "DATA_CORRECTION"}, + ) diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py index 30deff4..b3661e7 100644 --- a/api/tests/test_api_constraint_races.py +++ b/api/tests/test_api_constraint_races.py @@ -13,10 +13,19 @@ import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.models.access import AppUser from src.models.directories import RegionalOffice -from src.models.enums import RelatedGroupKind +from src.models.enums import PeriodQuarter, RelatedGroupKind +from src.repositories import consolidations as consolidation_repo from tests.conftest import AuthClientFactory -from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group +from tests.factories import ( + make_company_group, + make_consolidation, + make_legal_entity, + make_lock, + make_membership, + make_related_group, +) HTTP_OK = 200 HTTP_CREATED = 201 @@ -29,6 +38,7 @@ RELATED = "/directories/related-groups" USERS = "/admin/users" DIRECTORIES_REPO = "src.repositories.directories" USERS_REPO = "src.repositories.users" +CONSOLIDATIONS_REPO = "src.repositories.consolidations" GENERIC_DETAIL = "нарушение целостности данных в БД" @@ -246,3 +256,46 @@ async def test_consolidation_period_race( response = await client.post("/consolidations", json=body) _assert_coded(response, "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана", str(group.id)) + + +@pytest.mark.integration +async def test_consolidation_lock_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Активную блокировку pre-check не увидел, частичный UNIQUE отклонил вставку → 409 `LOCKED_BY_OTHER` с владельцем. + + Вставка идёт под SAVEPOINT: транзакция запроса не отравлена, сервис перечитывает владельца и отвечает доменной + ошибкой (а не общим 409 обработчика). + """ + office = ( + await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) + ).scalar_one() + group = await make_company_group(seeded, office=office) + entities = [await make_legal_entity(seeded, name=f"ООО Член {number}", group=group) for number in range(2)] + owner = (await seeded.execute(select(AppUser).where(AppUser.email == "rka@armka.local"))).scalar_one() + _, version = await make_consolidation( + seeded, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=owner + ) + await make_lock(seeded, version=version, user=owner) + original = consolidation_repo.active_lock + calls = 0 + + async def blind_once(session: AsyncSession, version_id: uuid.UUID) -> Any: + nonlocal calls + calls += 1 + return None if calls == 1 else await original(session, version_id) + + monkeypatch.setattr(f"{CONSOLIDATIONS_REPO}.active_lock", blind_once) + client = await auth_client("ka_rf@armka.local") + + response = await client.post(f"/consolidations/versions/{version.id}/lock") + + assert response.status_code == HTTP_CONFLICT, response.text + detail = response.json()["detail"] + assert (detail["code"], detail["locked_by"], detail["locked_by_name"]) == ( + "LOCKED_BY_OTHER", + str(owner.id), + owner.full_name, + ) + # транзакция запроса жива: следующий запрос того же клиента работает + assert (await client.get(f"/consolidations/versions/{version.id}")).json()["lock"]["locked_by"] == str(owner.id) diff --git a/api/tests/test_domain_policy.py b/api/tests/test_domain_policy.py index 557293f..f576bfe 100644 --- a/api/tests/test_domain_policy.py +++ b/api/tests/test_domain_policy.py @@ -6,6 +6,7 @@ Integration-часть — семантика SQL-фильтров на наст совпадает с проверкой отдельного объекта (`*_is_visible`). """ +import datetime import re import uuid from types import SimpleNamespace @@ -17,18 +18,23 @@ from sqlalchemy import Select, func, select from sqlalchemy.dialects import postgresql from sqlalchemy.ext.asyncio import AsyncSession -from src.common.exceptions import DomainValidationError +from src.common.exceptions import ConflictError, DomainValidationError from src.domain.policy import ( + FINISHED_STATUSES, + IN_WORK_STATUSES, VisibilityScope, company_group_is_visible, company_groups_visible, + ensure_version_editable, legal_entities_visible, legal_entity_is_visible, office_for_create, scope_for, scope_of, ) +from src.models.consolidation import ConsolidationLock, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import ConsolidationStatus from tests.factories import make_company_group, make_legal_entity, make_regional_office OFFICE_X = uuid.UUID("00000000-0000-0000-0000-0000000000aa") @@ -268,3 +274,59 @@ async def test_sql_filters_match_object_checks_on_real_db(db_session: AsyncSessi } assert await visible_entities(scope) == expected_entities assert await visible_groups(scope) == {gid for gid, g in groups.items() if company_group_is_visible(g, scope)} + + +# --- редактируемость версии консолидации (spec directory-policy «Видимость консолидаций и редактируемость версии») --- + +USER_A = uuid.UUID("00000000-0000-0000-0000-00000000000a") +USER_B = uuid.UUID("00000000-0000-0000-0000-00000000000b") +LOCKED_AT = datetime.datetime(2026, 10, 6, 9, 30, tzinfo=datetime.UTC) + + +def _version(status: ConsolidationStatus) -> ConsolidationVersion: + return ConsolidationVersion(id=uuid.uuid4(), status=status) + + +def _lock(user_id: uuid.UUID) -> ConsolidationLock: + return ConsolidationLock(id=uuid.uuid4(), locked_by=user_id, locked_at=LOCKED_AT) + + +def test_in_work_statuses_are_the_three_statuses_of_slice_4() -> None: + assert IN_WORK_STATUSES == { + ConsolidationStatus.INITIATED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, + } + assert not IN_WORK_STATUSES & FINISHED_STATUSES + + +@pytest.mark.parametrize( + ("status", "lock_owner", "expected_code"), + [ + (ConsolidationStatus.CANCELLED, USER_A, "VERSION_NOT_EDITABLE"), # статус проверяется до блокировки + (ConsolidationStatus.DATA_CORRECTION, None, "LOCK_REQUIRED"), + (ConsolidationStatus.INITIATED, USER_B, "LOCKED_BY_OTHER"), + (ConsolidationStatus.DOCUMENTS_PROCESSING, USER_A, None), + ], + ids=["not-in-work", "no-lock", "other-lock", "own-lock"], +) +def test_ensure_version_editable( + status: ConsolidationStatus, lock_owner: uuid.UUID | None, expected_code: str | None +) -> None: + lock = _lock(lock_owner) if lock_owner is not None else None + if expected_code is None: + ensure_version_editable(_version(status), lock, USER_A) + return + with pytest.raises(ConflictError) as caught: + ensure_version_editable(_version(status), lock, USER_A) + assert caught.value.status == http_status.HTTP_409_CONFLICT + assert caught.value.code == expected_code + if expected_code == "LOCKED_BY_OTHER": + assert caught.value.extra == {"locked_by": USER_B, "locked_at": LOCKED_AT} + + +@pytest.mark.parametrize("status", sorted(set(ConsolidationStatus) - IN_WORK_STATUSES)) +def test_version_outside_in_work_is_not_editable_even_by_lock_owner(status: ConsolidationStatus) -> None: + with pytest.raises(ConflictError) as caught: + ensure_version_editable(_version(status), _lock(USER_A), USER_A) + assert caught.value.code == "VERSION_NOT_EDITABLE" diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index df96806..48c1480 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -186,6 +186,7 @@ def test_registry_codes_and_messages_match_service_precheck_texts() -> None: "uq_related_group_member_group_entity_current": "MEMBER_EXISTS", "uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP", "uq_consolidation_group_year_quarter": "CONSOLIDATION_EXISTS", + "uq_consolidation_lock_version_active": "LOCKED_BY_OTHER", } # у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].message == "ЮЛ с таким ИНН уже существует" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 47275be..a2b81ec 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -55,6 +55,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations": {"post"}, "/api/v1/company-groups/{group_id}/consolidations": {"get"}, "/api/v1/consolidations/versions/{version_id}": {"get"}, + "/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"}, + "/api/v1/consolidations/versions/{version_id}/status": {"post"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -81,6 +83,8 @@ SINGLE_MODELS = [ "EntryOut", "VersionCard", "ConsolidationPeriodOut", + "LockOut", + "StatusIn", ] @@ -126,3 +130,14 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any assert {"200", "404"} <= set(card) listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"] assert {"200", "404"} <= set(listing) + + +def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: + """Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422.""" + lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"] + assert {"200", "201", "404", "409", "422"} <= set(lock["post"]["responses"]) + assert lock["post"]["responses"]["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/LockOut") + assert {"204", "404", "409"} <= set(lock["delete"]["responses"]) + change = schema["paths"]["/api/v1/consolidations/versions/{version_id}/status"]["post"]["responses"] + assert {"200", "404", "409", "422"} <= set(change) + assert change["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard") diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index e7b1a5b..1861668 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -103,9 +103,9 @@ - `ConsolidationLockService`: `take(version_id, data: LockIn) -> tuple[LockOut, bool created]` (`lock_version` → видимость → `status is CANCELLED` → 409 `VERSION_NOT_EDITABLE` → `active_lock`: нет → insert (`IntegrityError` → 409 `LOCKED_BY_OTHER`), своя → вернуть, чужая без причины → 409 `LOCKED_BY_OTHER`, с причиной → release + insert(`taken_over_from`, `takeover_reason`) + `CONSOLIDATION_LOCK_TAKEOVER`); `release(version_id) -> None` (своя → `released_at`, аудит `CONSOLIDATION_LOCK after.released = true`; чужая → 409 `NOT_LOCK_OWNER`; нет → noop). `release_active_lock(session, version_id, now)` — общая для `CANCELLED`. - Маршруты: `POST …/lock` (201 создана / 200 своя), `DELETE …/lock` (204), `POST …/status` (200). -- [ ] **Step 1: Тесты.** Домен: таблица `ensure_version_editable` (4 случая). API: сценарии спеки «Взять в работу, перехват, снятие», «Статусы Среза 4» (аннулирование снимает блокировку, `TRANSITION_NOT_ALLOWED` для `CONSOLIDATION_RF` и для повтора статуса, `COMMENT_REQUIRED`, возврат в `INITIATED`), `PUT …/members`-заглушка не нужна — `LOCK_REQUIRED` проверяется на `POST …/status`; гонка двух `POST …/lock` на `committed_client` (один 201, другой 409); Review Focus 4 — перехват во время правки (на `committed_sessionmaker`: сессия А держит `lock_version`, сессия Б вызывает перехват и ждёт; после commit А перехват проходит). -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_locks.py tests/test_api_consolidation_status.py tests/test_api_constraint_races.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): политика редактируемости, блокировка с перехватом, ручные статусы Среза 4`. +- [x] **Step 1: Тесты.** Домен: таблица `ensure_version_editable` (4 случая). API: сценарии спеки «Взять в работу, перехват, снятие», «Статусы Среза 4» (аннулирование снимает блокировку, `TRANSITION_NOT_ALLOWED` для `CONSOLIDATION_RF` и для повтора статуса, `COMMENT_REQUIRED`, возврат в `INITIATED`), `PUT …/members`-заглушка не нужна — `LOCK_REQUIRED` проверяется на `POST …/status`; гонка двух `POST …/lock` на `committed_client` (один 201, другой 409); Review Focus 4 — перехват во время правки (на `committed_sessionmaker`: сессия А держит `lock_version`, сессия Б вызывает перехват и ждёт; после commit А перехват проходит). +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_locks.py tests/test_api_consolidation_status.py tests/test_api_constraint_races.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): политика редактируемости, блокировка с перехватом, ручные статусы Среза 4`. --- diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index a5ad074..eee2af5 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -21,10 +21,10 @@ - [x] 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: +- [x] 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`. +- [x] 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`. Тесты: сценарии «Периметр версии». -- 2.47.2 From e317fe59dd9f6e3f6a5998b225bea581cf82abe4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 04:26:31 +0400 Subject: [PATCH 08/27] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20Task=204=20=E2=80=94=20=D0=BE=D0=B1=D1=89=D0=B8?= =?UTF-8?q?=D0=B9=20lock=5Fout,=20=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20#17=E2=80=93#24=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I-1 (T4.1): lock_out — публичный в services/consolidations.py, копия в consolidation_locks удалена - M-b (T4.3): удалён осиротевший комментарий в tests/factories.py - M-c (T4.4): точная проверка ip_address/user_agent в аудите смены статуса - M-d (T4.5): docstring теста Review Focus 4 ссылается на различающий тест порядка D-9 - design.md: решения без обсуждения #17 (R-8), #18 (R-9), #19–#24 (решения Task 4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/consolidation_locks.py | 18 ++++-------------- api/src/services/consolidations.py | 5 +++-- api/tests/factories.py | 1 - api/tests/test_api_consolidation_locks.py | 3 ++- api/tests/test_api_consolidation_status.py | 9 +++++++-- .../changes/slice-4-consolidation/design.md | 8 ++++++++ 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/api/src/services/consolidation_locks.py b/api/src/services/consolidation_locks.py index c834407..5465be1 100644 --- a/api/src/services/consolidation_locks.py +++ b/api/src/services/consolidation_locks.py @@ -25,7 +25,7 @@ from src.models.enums import AuditAction, AuditSection, ConsolidationStatus from src.repositories import consolidations as repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import LockIn, LockOut -from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND +from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, lock_out ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active" @@ -51,16 +51,6 @@ async def _user_name(session: AsyncSession, user_id: uuid.UUID) -> str: return user.full_name if user is not None else "" -def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: - return LockOut( - locked_by=lock.locked_by, - locked_by_name=locked_by_name, - locked_at=lock.locked_at, - taken_over_from=lock.taken_over_from, - takeover_reason=lock.takeover_reason, - ) - - class ConsolidationLockService: """Взятие версии в работу от имени текущего пользователя в его области видимости.""" @@ -86,7 +76,7 @@ class ConsolidationLockService: user = self._principal.user current = await repo.active_lock(self._session, version.id) if current is not None and current.locked_by == user.id: - return _lock_out(current, user.full_name), False + return lock_out(current, user.full_name), False reason = (data.takeover_reason or "").strip() or None if current is not None and reason is None: raise await self._locked_by_other(current) @@ -102,7 +92,7 @@ class ConsolidationLockService: consolidation_version_id=version.id, after_value={"locked_by": user.id, "released": False}, ) - return _lock_out(lock, user.full_name), True + return lock_out(lock, user.full_name), True previous_owner = current.locked_by await release_active_lock(self._session, version.id, now) @@ -117,7 +107,7 @@ class ConsolidationLockService: after_value={"locked_by": user.id}, comment=reason, ) - return _lock_out(lock, user.full_name), True + return lock_out(lock, user.full_name), True async def release(self, version_id: uuid.UUID) -> None: """Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409 diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 759f99e..7e022cd 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -58,7 +58,8 @@ MIN_PERIMETER = 2 # в периметре консолидации не мен FIRST_VERSION_NO = 1 -def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: +def lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: + """Активная блокировка в ответе API (список, карточка, `POST …/lock`).""" return LockOut( locked_by=lock.locked_by, locked_by_name=locked_by_name, @@ -211,7 +212,7 @@ class ConsolidationService: status=version.status, scenario=version.scenario, approach=version.approach, - lock=_lock_out(*lock) if lock is not None else None, + lock=lock_out(*lock) if lock is not None else None, created_at=version.created_at, members_count=members_count, ) diff --git a/api/tests/factories.py b/api/tests/factories.py index 492a925..0fc5f18 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -76,7 +76,6 @@ TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) FACTORY_BUCKET = "arm-ka" -# статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию def _now() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) diff --git a/api/tests/test_api_consolidation_locks.py b/api/tests/test_api_consolidation_locks.py index 8e54b93..87a27ab 100644 --- a/api/tests/test_api_consolidation_locks.py +++ b/api/tests/test_api_consolidation_locks.py @@ -384,7 +384,8 @@ async def test_takeover_waits_for_owner_edit_then_owner_loses_lock( """Review Focus 4: владелец А правит периметр (`consolidation_version FOR UPDATE`), Б в это время перехватывает. Перехват ждёт транзакцию А; после её commit правка А зафиксирована, Б — владелец, следующая правка А — - 409 `LOCKED_BY_OTHER`. + 409 `LOCKED_BY_OTHER`. Этот сценарий ждал бы и без `FOR UPDATE` версии в `take` (вставка блокировки берёт FK + `KEY SHARE` на версию); порядок D-9 различает `test_takeover_during_cancellation_sees_cancelled_version`. """ async with committed_sessionmaker() as setup, setup.begin(): version = await _version(setup) diff --git a/api/tests/test_api_consolidation_status.py b/api/tests/test_api_consolidation_status.py index 028841b..8753ff0 100644 --- a/api/tests/test_api_consolidation_status.py +++ b/api/tests/test_api_consolidation_status.py @@ -118,7 +118,11 @@ async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: Asyn user = await _user(seeded, KA_RF) client = await auth_client(KA_RF) - response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Дубль"}) + response = await client.post( + _status_url(version.id), + json={"to_status": "CANCELLED", "comment": "Дубль"}, + headers={"User-Agent": "pytest-status"}, + ) assert response.status_code == HTTP_OK, response.text card = response.json() @@ -155,7 +159,8 @@ async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: Asyn {"status": "CANCELLED"}, "Дубль", ) - assert event.ip_address is not None or event.user_agent is not None # контекст запроса передан в аудит + # контекст запроса передан в аудит (ASGITransport отдаёт клиента 127.0.0.1) + assert (event.ip_address, event.user_agent) == ("127.0.0.1", "pytest-status") @pytest.mark.integration diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 52acf20..985ce6f 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -201,3 +201,11 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 14 | 2026-10-06 | R-4: `Resolution(entity_id, kind)` определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` импортирует и реэкспортирует | `domain/consolidated_osv.py` (mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`, домен не импортирует репозитории | нулевая (перенос определения) | | 15 | 2026-10-06 | R-5: Task 2 создаёт `domain/checklist.py` с `EntityFilesState(has_in_progress, last_file_status)` и `entity_files_state(session, entity_ids)`; `unconfirmed_documents(...)` и `build` добавляет Task 8 | план описывал `entity_files_state` и в Task 2, и в Task 8; `build` получает и `files_state`, и `unbound` | нулевая (разнесение по задачам) | | 16 | 2026-10-06 | `osv_entry.counterparty_match_kind` — последняя колонка таблицы (в ORM и DBML), а не сразу после `counterparty_id` | `ADD COLUMN` в миграции 0004 добавляет колонку в конец, а `test_migrated_schema_equals_orm_create_all` сравнивает `ordinal_position`; пересоздание таблицы с 200 тыс. строк ради порядка колонок не оправдано | косметика: порядок колонок в `SELECT *`/диаграмме; на поведение не влияет | +| 17 | 2026-10-06 | R-8: коды 404 `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` реализованы как текст сообщения при фиксированном коде `NOT_FOUND` | конвенция Срезов 1–2c: `NotFoundError` с фиксированным кодом, как `COMPANY_GROUP_NOT_FOUND` | если нужны отдельные коды — меняется конструктор ошибки и тесты | +| 18 | 2026-10-06 | R-9: `in_work_only` убирает период без оставшихся версий; невидимая/несуществующая ГК в списке → 404; неизвестная/невидимая ГСК → 404 (сообщение `RELATED_GROUP_NOT_FOUND`), видимая ГСК чужой ГК или `kind = gsz` → 422 `RELATED_GROUP_MISMATCH` | естественное продолжение policy «невидимое = несуществующее» (404); несоответствие видимой ГСК — ошибка запроса | расхождение с ожиданием фронта по пустому списку | +| 19 | 2026-10-06 | `takeover_reason` пустой или из одних пробелов считается отсутствующим: при чужой блокировке — 409 `LOCKED_BY_OTHER`, а не 422; при свободной версии причина не сохраняется (`taken_over_from` NULL, обычное взятие 201) | спека требует «непустую» причину только для перехвата; при свободной версии перехватывать не у кого | фронт не получит 422 на пустую причину; в БД нет «причины» у обычного взятия | +| 20 | 2026-10-06 | Комментарий `POST …/status` обрезается по краям; из одних пробелов — пустой (`COMMENT_REQUIRED` для `CANCELLED`); в `cancelled_reason` и историю пишется обрезанный текст | «пустой комментарий» по спеке включает пробельный; обрезка не меняет смысла | косметика: ведущие/хвостовые пробелы комментария теряются | +| 21 | 2026-10-06 | `NOT_LOCK_OWNER` отдаёт в `detail` `locked_by` и `locked_at` владельца (по аналогии с `LOCKED_BY_OTHER`) | спека полей не перечисляет; фронту нужно показать, у кого блокировка | нулевая (лишние поля ответа) | +| 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) | +| 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ | +| 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая | -- 2.47.2 From 90b7bd5c16991593234e1b13c55b67551272aaf7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 04:34:52 +0400 Subject: [PATCH 09/27] =?UTF-8?q?feat(consolidation):=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=85=D0=B2=D0=B0=D1=82=D0=BE=D0=BC=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D1=8F=D0=B7=D0=BA=D0=BE=D0=B9=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /consolidations/versions/{id}/members: замена состава ЮЛ версии, проверка принадлежности ГК (ENTITY_NOT_IN_GROUP), минимум два ЮЛ, diff с отвязкой документов ушедших (unbind_entity) и подхватом добавленных (bind_confirmed, заглушка до Task 6), аудит MEMBERSHIP_CHANGE. Общий пролог «lock → видимость → редактируемость» вынесен в services/consolidation_guard.py (T4.2). Решения #25–#29. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../api/endpoints/consolidations/__init__.py | 3 +- .../api/endpoints/consolidations/members.py | 38 ++ api/src/repositories/consolidations.py | 10 + api/src/services/consolidation_documents.py | 27 +- api/src/services/consolidation_guard.py | 40 ++ api/src/services/consolidation_locks.py | 18 +- api/src/services/consolidation_status.py | 13 +- api/src/services/consolidations.py | 58 ++- api/tests/test_api_consolidation_members.py | 493 ++++++++++++++++++ api/tests/test_openapi_routes.py | 1 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 5 + .../changes/slice-4-consolidation/tasks.md | 2 +- 13 files changed, 685 insertions(+), 29 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/members.py create mode 100644 api/src/services/consolidation_guard.py create mode 100644 api/tests/test_api_consolidation_members.py diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index c23ab35..413e891 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import locks, periods, status, versions +from src.api.endpoints.consolidations import locks, members, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) router.include_router(versions.router) router.include_router(locks.router) router.include_router(status.router) +router.include_router(members.router) diff --git a/api/src/api/endpoints/consolidations/members.py b/api/src/api/endpoints/consolidations/members.py new file mode 100644 index 0000000..8f14963 --- /dev/null +++ b/api/src/api/endpoints/consolidations/members.py @@ -0,0 +1,38 @@ +"""`PUT /consolidations/versions/{id}/members` — периметр версии консолидации (spec consolidations-api «Периметр +версии»; directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-8, D-9).""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import MembersIn, VersionCard + +router = APIRouter(prefix="/consolidations/versions") + +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "ENTITY_NOT_IN_GROUP (legal_entity_ids), PERIMETER_TOO_SMALL или ошибка валидации тела" + }, +} + + +@router.put( + "/{version_id}/members", + response_model=VersionCard, + dependencies=EDIT, + responses=RESPONSES, + summary="Заменить периметр версии", +) +async def set_members(version_id: uuid.UUID, data: MembersIn, service: ConsolidationServiceDep) -> VersionCard: + """Новый состав ЮЛ версии (все — из ГК консолидации, не меньше двух; нужна своя блокировка).""" + return await service.set_members(version_id, data) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 7f19cf9..daa2527 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -182,6 +182,16 @@ async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[Co return [(row[0], row[1]) for row in await session.execute(stmt)] +async def entities_in_group( + session: AsyncSession, group_id: uuid.UUID, entity_ids: Collection[uuid.UUID] +) -> set[uuid.UUID]: + """Из `entity_ids` — те ЮЛ, что существуют и принадлежат ГК `group_id` (`legal_entity.company_group_id`).""" + if not entity_ids: + return set() + stmt = select(LegalEntity.id).where(LegalEntity.id.in_(entity_ids), LegalEntity.company_group_id == group_id) + return set((await session.execute(stmt)).scalars()) + + async def bound_documents(session: AsyncSession, version_id: uuid.UUID) -> list[Document]: """Документы, привязанные к версии (`consolidation_document`).""" stmt = ( diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 68d63fe..63f7d23 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -2,16 +2,21 @@ ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок -файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызов из -`services.consolidations.ConsolidationService.create` сохраняет. +файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызовы из +`services.consolidations.ConsolidationService` (`create`, `set_members`) сохраняет. + +`unbind_entity` (Task 5) — прямое удаление привязок ЮЛ к версии; Task 6 владеет функцией и вправе переписать тело +(имя и сигнатура — прежние): вызывается из `set_members` для ЮЛ, ушедших из периметра. """ import uuid from collections.abc import Sequence +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.consolidation import ConsolidationVersion +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.files import Document async def bind_confirmed( @@ -21,3 +26,19 @@ async def bind_confirmed( ) -> int: """Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок.""" return 0 + + +async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_id: uuid.UUID) -> int: + """Снимает все привязки документов ЮЛ `entity_id` к версии `version_id`; возвращает число снятых. + + Task 5: простое прямое удаление `consolidation_document` по `document.legal_entity_id`. Принадлежит Task 6 + (отвязка по правилам D-5, порядок блокировок); версия должна быть под `FOR UPDATE` вызывающего. + """ + owned = select(Document.id).where(Document.legal_entity_id == entity_id) + result = await session.execute( + delete(ConsolidationDocument).where( + ConsolidationDocument.consolidation_version_id == version_id, + ConsolidationDocument.document_id.in_(owned), + ) + ) + return int(result.rowcount) # type: ignore[attr-defined] # CursorResult у DML; у базового Result атрибута нет diff --git a/api/src/services/consolidation_guard.py b/api/src/services/consolidation_guard.py new file mode 100644 index 0000000..cdaa927 --- /dev/null +++ b/api/src/services/consolidation_guard.py @@ -0,0 +1,40 @@ +"""Общий пролог операций над версией консолидации: блокировка строки версии и видимость (design D-8, D-9). + +Один порядок для всех операций, меняющих версию: `consolidation_version FOR UPDATE` → видимость (ГК консолидации в +области пользователя; чужая и несуществующая версии — один и тот же 404 `VERSION_NOT_FOUND`) → для правок — +активная блокировка и `ensure_version_editable` (409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`). +Версию блокирует и правка, и перехват, и подтверждение документа (R-2), поэтому проверка статуса и владельца +блокировки не может устареть до конца транзакции. Операции, которым редактируемость не нужна (взять в работу, снять +блокировку), берут только `lock_visible_version`. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import NotFoundError +from src.domain.policy import VisibilityScope, ensure_version_editable +from src.models.consolidation import Consolidation, ConsolidationVersion +from src.repositories import consolidations as repo + +VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей + + +async def lock_visible_version( + session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID +) -> tuple[ConsolidationVersion, Consolidation]: + """Версия под `FOR UPDATE` и её консолидация, если ГК видна пользователю; иначе 404 `VERSION_NOT_FOUND`.""" + version = await repo.lock_version(session, version_id) + found = None if version is None else await repo.get_version(session, scope, version_id) + if version is None or found is None: + raise NotFoundError(VERSION_NOT_FOUND) + return version, found[1] + + +async def lock_editable_version( + session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID, user_id: uuid.UUID +) -> tuple[ConsolidationVersion, Consolidation]: + """`lock_visible_version` + `ensure_version_editable` по активной блокировке: версию вправе править `user_id`.""" + version, consolidation = await lock_visible_version(session, scope, version_id) + ensure_version_editable(version, await repo.active_lock(session, version.id), user_id) + return version, consolidation diff --git a/api/src/services/consolidation_locks.py b/api/src/services/consolidation_locks.py index 5465be1..77ad80c 100644 --- a/api/src/services/consolidation_locks.py +++ b/api/src/services/consolidation_locks.py @@ -17,15 +17,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.constraint_conflicts import constraint_name, resolve_unique_conflict -from src.common.exceptions import ConflictError, NotFoundError +from src.common.exceptions import ConflictError from src.domain.policy import VisibilityScope from src.models.access import AppUser -from src.models.consolidation import ConsolidationLock, ConsolidationVersion +from src.models.consolidation import ConsolidationLock from src.models.enums import AuditAction, AuditSection, ConsolidationStatus from src.repositories import consolidations as repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import LockIn, LockOut -from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, lock_out +from src.services.consolidation_guard import lock_visible_version +from src.services.consolidations import OBJECT_TYPE, lock_out ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active" @@ -70,7 +71,7 @@ class ConsolidationLockService: непустой причиной — прежняя снимается, новая с `taken_over_from`/`takeover_reason`, аудит перехвата. Причина при свободной версии не сохраняется (перехватывать не у кого). """ - version = await self._locked_visible_version(version_id) + version, _ = await lock_visible_version(self._session, self._scope, version_id) if version.status == ConsolidationStatus.CANCELLED: raise ConflictError("VERSION_NOT_EDITABLE", "Аннулированную версию нельзя взять в работу") user = self._principal.user @@ -112,7 +113,7 @@ class ConsolidationLockService: async def release(self, version_id: uuid.UUID) -> None: """Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409 `NOT_LOCK_OWNER`; активной нет — ничего не делает (идемпотентно).""" - version = await self._locked_visible_version(version_id) + version, _ = await lock_visible_version(self._session, self._scope, version_id) current = await repo.active_lock(self._session, version.id) if current is None: return @@ -134,13 +135,6 @@ class ConsolidationLockService: after_value={"locked_by": user.id, "released": True}, ) - async def _locked_visible_version(self, version_id: uuid.UUID) -> ConsolidationVersion: - """Версия под `FOR UPDATE` (D-9), если её ГК видна; иначе 404 — одинаковый для чужой и несуществующей.""" - version = await repo.lock_version(self._session, version_id) - if version is None or await repo.get_version(self._session, self._scope, version_id) is None: - raise NotFoundError(VERSION_NOT_FOUND) - return version - async def _insert( self, version_id: uuid.UUID, diff --git a/api/src/services/consolidation_status.py b/api/src/services/consolidation_status.py index 97e9a0a..ca9bec0 100644 --- a/api/src/services/consolidation_status.py +++ b/api/src/services/consolidation_status.py @@ -13,16 +13,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now -from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError -from src.domain.policy import VisibilityScope, ensure_version_editable +from src.common.exceptions import ConflictError, DomainValidationError +from src.domain.policy import VisibilityScope from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion from src.models.enums import AuditAction, AuditSection, ConsolidationStatus -from src.repositories import consolidations as repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import StatusIn, VersionCard from src.services import consolidation_locks from src.services.audit import AuditService -from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, ConsolidationService +from src.services.consolidation_guard import lock_editable_version +from src.services.consolidations import OBJECT_TYPE, ConsolidationService _CANCELLED = ConsolidationStatus.CANCELLED _INITIATED = ConsolidationStatus.INITIATED @@ -111,11 +111,8 @@ class ConsolidationStatusService: с `from_status`, `to_status`; повтор того же статуса — тоже) → `CANCELLED` без комментария — 422 `COMMENT_REQUIRED`. Комментарий из одних пробелов считается пустым. """ - version = await repo.lock_version(self._session, version_id) - if version is None or await repo.get_version(self._session, self._scope, version_id) is None: - raise NotFoundError(VERSION_NOT_FOUND) user = self._principal.user - ensure_version_editable(version, await repo.active_lock(self._session, version.id), user.id) + version, _ = await lock_editable_version(self._session, self._scope, version_id, user.id) if data.to_status not in MANUAL_TRANSITIONS.get(version.status, frozenset()): raise ConflictError( "TRANSITION_NOT_ALLOWED", diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 7e022cd..1deca69 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -44,15 +44,16 @@ from src.schemas.consolidations import ( HistoryOut, LockOut, MemberOut, + MembersIn, RefOut, VersionBrief, VersionCard, ) from src.services import consolidation_documents from src.services.company_groups import COMPANY_GROUP_NOT_FOUND +from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version from src.services.related_groups import RELATED_GROUP_NOT_FOUND -VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей OBJECT_TYPE = "consolidation_version" MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) FIRST_VERSION_NO = 1 @@ -155,6 +156,61 @@ class ConsolidationService: raise NotFoundError(VERSION_NOT_FOUND) return await self._card(*found) + async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard: + """Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии. + + Порядок: `FOR UPDATE` версии → видимость (404) → редактируемость и своя блокировка (409, D-8, D-9) → дубли + схлопываются → все ЮЛ существуют и принадлежат ГК консолидации (422 `ENTITY_NOT_IN_GROUP`, + `legal_entity_ids` — в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`) → diff: ушедшие + теряют `consolidation_member` и привязки документов (`unbind_entity`), пришедшие получают член + (`is_head_entity = false`) и подхват подтверждённых документов (`bind_confirmed`); оставшиеся не трогаются → + `MEMBERSHIP_CHANGE` с полными отсортированными списками ЮЛ до/после. Состав не изменился — 200 без записи и + без события. `related_group_member` не меняется никогда. + """ + user = self._principal.user + version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id) + requested = list(dict.fromkeys(data.legal_entity_ids)) + valid = await repo.entities_in_group(self._session, consolidation.company_group_id, requested) + foreign = [entity_id for entity_id in requested if entity_id not in valid] + if foreign: + raise DomainValidationError( + "ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign + ) + if len(requested) < MIN_PERIMETER: + raise DomainValidationError( + "PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}" + ) + + current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)} + added = [entity_id for entity_id in requested if entity_id not in current] + removed = [entity_id for entity_id in current if entity_id not in set(requested)] + if not added and not removed: + return await self._card(version, consolidation) + + for entity_id in removed: + await self._session.delete(current[entity_id]) + self._session.add_all( + ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False) + for entity_id in added + ) + version.updated_by = user.id + version.updated_at = utc_now() + await self._session.flush() + for entity_id in removed: + await consolidation_documents.unbind_entity(self._session, version_id=version.id, entity_id=entity_id) + if added: + await consolidation_documents.bind_confirmed(self._session, version, added) + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + before_value={"legal_entity_ids": sorted(current)}, + after_value={"legal_entity_ids": sorted(requested)}, + ) + return await self._card(version, consolidation) + async def _insert( self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity] ) -> tuple[Consolidation, ConsolidationVersion]: diff --git a/api/tests/test_api_consolidation_members.py b/api/tests/test_api_consolidation_members.py new file mode 100644 index 0000000..c258bf8 --- /dev/null +++ b/api/tests/test_api_consolidation_members.py @@ -0,0 +1,493 @@ +"""Периметр версии: `PUT /consolidations/versions/{id}/members` (spec consolidations-api «Периметр версии»; +directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-5, D-8, D-9). + +Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`) до Task 6 — заглушка: здесь проверяется ВЫЗОВ +(шпион на функцию модуля), а настоящие привязки появятся с реализацией и ячейкой `CONFIRMED` (Task 6, Task 8). +Отвязка документов удалённых ЮЛ (`unbind_entity`) работает уже сейчас — прямое удаление `consolidation_document`. +""" + +import uuid +from collections.abc import Sequence +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationMember, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroupMember +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter +from src.services import consolidation_documents +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_legal_entity, + make_lock, + make_membership, + make_related_group, +) + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" +CURATOR = "curator_go@armka.local" +EXPERT = "expert@armka.local" +VERSIONS = "/consolidations/versions" +ACCOUNT = "60" +THREE = 3 + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _version( # noqa: PLR0913 - тестовая фабрика: состав периметра, статус, филиал и владелец блокировки + session: AsyncSession, + *, + members: int = 2, + spare: int = 1, + status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION, + office_name: str = ROSTOV, + locked_by: str | None = KA_RF, +) -> tuple[ConsolidationVersion, list[LegalEntity], list[LegalEntity], uuid.UUID]: + """Версия ГК филиала за 2024 Q3: `members` ЮЛ в периметре и `spare` ЮЛ той же ГК вне периметра. + + Возвращает версию, члены, запасные ЮЛ и id ГК; `locked_by` — владелец активной блокировки. + """ + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + inside = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)] + outside = [await make_legal_entity(session, name=f"ООО Запас {n}", group=group) for n in range(spare)] + _, version = await make_consolidation( + session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=inside, + initiator=await _user(session, KA_RF), + status=status, + ) + if locked_by is not None: + await make_lock(session, version=version, user=await _user(session, locked_by)) + return version, inside, outside, group.id + + +def _url(version_id: uuid.UUID) -> str: + return f"{VERSIONS}/{version_id}/members" + + +def _body(entities: Sequence[LegalEntity | uuid.UUID]) -> dict[str, Any]: + return {"legal_entity_ids": [str(e.id if isinstance(e, LegalEntity) else e) for e in entities]} + + +async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationMember.legal_entity_id).where(ConsolidationMember.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _bound_documents(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _events(session: AsyncSession) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE) + return list((await session.execute(stmt)).scalars()) + + +# --- добавление ---------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_add_member_updates_perimeter_and_picks_up_documents( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Добавленное ЮЛ — в периметре и в карточке; подхват подтверждённых документов вызван ровно для добавленных.""" + version, inside, outside, _ = await _version(seeded) + await make_confirmed_osv( + seeded, + entity=outside[0], + account=ACCOUNT, + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}], + ) + calls: list[tuple[uuid.UUID, list[uuid.UUID]]] = [] + + async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int: + calls.append((bound_version.id, list(entity_ids))) + return 0 + + monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_OK + card = response.json() + assert {m["id"] for m in card["members"]} == {str(e.id) for e in [*inside, outside[0]]} + assert card["members_count"] == THREE + assert calls == [(version.id, [outside[0].id])] + assert await _member_ids(seeded, version.id) == {e.id for e in [*inside, outside[0]]} + + +@pytest.mark.integration +async def test_unchanged_members_are_not_touched_by_picking_up( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Подхват не вызывается для ЮЛ, которые уже в периметре, и не вызывается, если добавлять некого.""" + version, inside, _, _ = await _version(seeded, members=THREE, spare=0) + calls: list[list[uuid.UUID]] = [] + + async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int: + calls.append(list(entity_ids)) + return 0 + + monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body(inside[:2])) + + assert response.status_code == HTTP_OK + assert calls == [] + + +@pytest.mark.integration +async def test_duplicates_are_collapsed(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, outside, _ = await _version(seeded) + client = await auth_client(KA_RF) + ids = [outside[0], inside[0], outside[0], inside[1], inside[0]] + + response = await client.put(_url(version.id), json=_body(ids)) + + assert response.status_code == HTTP_OK + assert response.json()["members_count"] == THREE + assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id, outside[0].id} + count = await seeded.execute( + select(func.count()) + .select_from(ConsolidationMember) + .where(ConsolidationMember.consolidation_version_id == version.id) + ) + assert count.scalar_one() == THREE + + +# --- удаление ------------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_removing_member_drops_its_bindings_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Удалённое ЮЛ теряет привязки документов к этой версии; документы оставшихся и других версий не затронуты.""" + version, inside, _, _ = await _version(seeded, members=THREE, spare=0) + documents = {} + for entity in inside: + documents[entity.id] = await make_confirmed_osv( + seeded, + entity=entity, + account=ACCOUNT, + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}], + ) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=documents[entity.id].id)) + other, other_entities, _, _ = await _version(seeded, spare=0, locked_by=None) + seeded.add(ConsolidationDocument(consolidation_version_id=other.id, document_id=documents[inside[2].id].id)) + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body(inside[:2])) + + assert response.status_code == HTTP_OK + assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id} + assert await _bound_documents(seeded, version.id) == {documents[inside[0].id].id, documents[inside[1].id].id} + assert await _bound_documents(seeded, other.id) == {documents[inside[2].id].id} + + +@pytest.mark.integration +async def test_unbind_entity_removes_only_that_entitys_documents_of_that_version(seeded: AsyncSession) -> None: + """`unbind_entity` (Task 6 заменит тело, имя и сигнатура — прежние) возвращает число снятых привязок.""" + version, inside, _, _ = await _version(seeded, locked_by=None) + docs = [] + for entity in inside: + document = await make_confirmed_osv( + seeded, + entity=entity, + account=ACCOUNT, + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}], + ) + docs.append(document) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await seeded.flush() + + removed = await consolidation_documents.unbind_entity(seeded, version_id=version.id, entity_id=inside[0].id) + + assert removed == 1 + assert await _bound_documents(seeded, version.id) == {docs[1].id} + + +# --- проверки ------------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_entity_of_other_group_is_rejected(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, _, _ = await _version(seeded) + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + stranger_group = await make_company_group(seeded, name="ГК Чужая", office=office) + stranger = await make_legal_entity(seeded, name="ООО Чужое", group=stranger_group) + ungrouped = await make_legal_entity(seeded, name="ООО Без ГК") + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, stranger, ungrouped])) + + assert response.status_code == HTTP_UNPROCESSABLE + detail = response.json()["detail"] + assert detail["code"] == "ENTITY_NOT_IN_GROUP" + assert set(detail["legal_entity_ids"]) == {str(stranger.id), str(ungrouped.id)} + assert await _member_ids(seeded, version.id) == {e.id for e in inside} + assert await _events(seeded) == [] + + +@pytest.mark.integration +async def test_unknown_entity_is_rejected_as_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, _, _ = await _version(seeded) + client = await auth_client(KA_RF) + ghost = uuid.uuid4() + + response = await client.put(_url(version.id), json=_body([*inside, ghost])) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP" + assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)] + + +@pytest.mark.integration +@pytest.mark.parametrize("count", [0, 1]) +async def test_perimeter_smaller_than_two_is_rejected( + auth_client: AuthClientFactory, seeded: AsyncSession, count: int +) -> None: + version, inside, _, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body(inside[:count])) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL" + assert await _member_ids(seeded, version.id) == {e.id for e in inside} + + +@pytest.mark.integration +async def test_duplicates_do_not_make_a_perimeter_of_two(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Два одинаковых id — это один ЮЛ: меньше двух.""" + version, inside, _, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([inside[0], inside[0]])) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL" + + +@pytest.mark.integration +async def test_foreign_entity_is_reported_before_size(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Порядок проверок: принадлежность ГК раньше размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`).""" + version, _, _, _ = await _version(seeded) + stranger = await make_legal_entity(seeded, name="ООО Чужое") + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([stranger])) + + assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP" + + +# --- редактируемость, видимость, право ----------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_members_without_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, outside, _ = await _version(seeded, locked_by=None) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCK_REQUIRED" + assert await _member_ids(seeded, version.id) == {e.id for e in inside} + + +@pytest.mark.integration +async def test_members_with_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, outside, _ = await _version(seeded, locked_by=RKA) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_CONFLICT + detail = response.json()["detail"] + assert detail["code"] == "LOCKED_BY_OTHER" + assert detail["locked_by"] == str((await _user(seeded, RKA)).id) + + +@pytest.mark.integration +async def test_members_of_cancelled_version_is_not_editable( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + version, inside, outside, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + + +@pytest.mark.integration +async def test_editability_is_checked_before_body_validation( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Без блокировки даже заведомо неверный состав даёт `LOCK_REQUIRED` (порядок спеки: редактируемость раньше).""" + version, _, _, _ = await _version(seeded, locked_by=None) + stranger = await make_legal_entity(seeded, name="ООО Чужое") + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([stranger])) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCK_REQUIRED" + + +@pytest.mark.integration +async def test_members_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, inside, outside, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None) + client = await auth_client(KA_RF) + body = _body([*inside, outside[0]]) + + foreign = await client.put(_url(version.id), json=body) + missing = await client.put(_url(uuid.uuid4()), json=body) + + assert foreign.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + + +@pytest.mark.integration +@pytest.mark.parametrize("email", [CURATOR, EXPERT]) +async def test_members_requires_consolidation_edit( + auth_client: AuthClientFactory, seeded: AsyncSession, email: str +) -> None: + version, inside, outside, _ = await _version(seeded) + client = await auth_client(email) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_members_rejects_malformed_body(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, _, _, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json={"legal_entity_ids": ["не-uuid"]}) + + assert response.status_code == HTTP_UNPROCESSABLE + + +# --- аудит и независимость от ГСК ---------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_membership_change_is_audited_with_full_sorted_lists( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + version, inside, outside, _ = await _version(seeded, members=THREE, spare=1) + client = await auth_client(KA_RF) + new = [inside[0], inside[1], outside[0]] # inside[2] ушёл, outside[0] пришёл + + response = await client.put(_url(version.id), json=_body(new)) + + assert response.status_code == HTTP_OK + (event,) = await _events(seeded) + assert event.section == AuditSection.CONSOLIDATION + assert event.object_type == "consolidation_version" + assert event.object_id == version.id + assert event.consolidation_version_id == version.id + assert event.user_id == (await _user(seeded, KA_RF)).id + assert event.before_value == {"legal_entity_ids": sorted(str(e.id) for e in inside)} + assert event.after_value == {"legal_entity_ids": sorted(str(e.id) for e in new)} + + +@pytest.mark.integration +async def test_same_perimeter_is_ok_without_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Состав не изменился (в том числе порядок id другой): 200 с карточкой, события нет (design #25).""" + version, inside, _, _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body(list(reversed(inside)))) + + assert response.status_code == HTTP_OK + assert response.json()["members_count"] == len(inside) + assert await _events(seeded) == [] + + +@pytest.mark.integration +async def test_kept_members_and_related_group_are_untouched( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Оставшиеся члены не пересоздаются (`is_head_entity`, `id`), `related_group_member` не меняется.""" + version, inside, outside, group_id = await _version(seeded, members=THREE, spare=1) + head = ( + await seeded.execute( + select(ConsolidationMember).where( + ConsolidationMember.consolidation_version_id == version.id, + ConsolidationMember.legal_entity_id == inside[0].id, + ) + ) + ).scalar_one() + head.is_head_entity = True + group = await seeded.get(CompanyGroup, group_id) + assert group is not None + related = await make_related_group(seeded, group=group) + for entity in inside: + await make_membership(seeded, related_group=related, entity=entity) + await seeded.flush() + before = await _related_state(seeded, related.id) + kept_id = head.id + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([inside[0], inside[1], outside[0]])) + + assert response.status_code == HTTP_OK + assert await _related_state(seeded, related.id) == before + kept = ( + await seeded.execute( + select(ConsolidationMember) + .where(ConsolidationMember.id == kept_id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + assert kept.is_head_entity is True + head_flags = {m["id"]: m["is_head_entity"] for m in response.json()["members"]} + assert head_flags[str(inside[0].id)] is True + assert head_flags[str(outside[0].id)] is False + + +async def _related_state(session: AsyncSession, related_group_id: uuid.UUID) -> list[tuple[uuid.UUID, bool, Any]]: + stmt = ( + select(RelatedGroupMember.legal_entity_id, RelatedGroupMember.is_current, RelatedGroupMember.valid_to) + .where(RelatedGroupMember.related_group_id == related_group_id) + .order_by(RelatedGroupMember.legal_entity_id) + ) + return [(row[0], row[1], row[2]) for row in await session.execute(stmt)] diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index a2b81ec..e240f13 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -57,6 +57,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}": {"get"}, "/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"}, "/api/v1/consolidations/versions/{version_id}/status": {"post"}, + "/api/v1/consolidations/versions/{version_id}/members": {"put"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 1861668..971ddc1 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -119,9 +119,9 @@ - `ConsolidationService.set_members(version_id, data: MembersIn) -> VersionCard`: `lock_version` → видимость → `ensure_version_editable` → уникальные id → все ЮЛ с `company_group_id == consolidation.company_group_id` (иначе 422 `ENTITY_NOT_IN_GROUP`, `extra.legal_entity_ids`) → ≥ 2 → diff: удалить `ConsolidationMember` ушедших + `consolidation_documents.unbind_entity(version, entity_id)` (Task 6; до него — удаление `consolidation_document` по `document.legal_entity_id` прямо здесь), добавить новых + `bind_confirmed` → `audit(MEMBERSHIP_CHANGE, before = {legal_entity_ids: старые}, after = {legal_entity_ids: новые})`. - Маршрут: `PUT …/members` (200, карточка). -- [ ] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился). -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`. +- [x] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился). +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 985ce6f..c8f6429 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -209,3 +209,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) | | 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ | | 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая | +| 25 | 2026-10-06 | `PUT …/members` с составом, равным текущему, отвечает 200 с карточкой, ничего не пишет (ни членов, ни `updated_at`) и НЕ пишет `MEMBERSHIP_CHANGE` | событие «периметр изменён» без изменения вводит в заблуждение и засоряет журнал; идемпотентный повтор запроса (двойной клик, ретрай) безопасен | в журнале нет следа повторного сохранения того же состава (оно ничего не меняло) | +| 26 | 2026-10-06 | `ENTITY_NOT_IN_GROUP` покрывает три случая: ЮЛ другой ГК, ЮЛ без ГК и несуществующий id; `legal_entity_ids` — в порядке запроса. Проверка идёт ДО проверки размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`, не `PERIMETER_TOO_SMALL`); дубли схлопываются ДО подсчёта размера (`[a, a]` → `PERIMETER_TOO_SMALL`) | спека: «все ЮЛ SHALL принадлежать ГК» — несуществующее ЮЛ ей не принадлежит; существование отдельным кодом не различаем, чтобы не раскрывать чужие id (как 404 вместо 403) | клиент не отличает «нет такого ЮЛ» от «ЮЛ чужой ГК» | +| 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая | +| 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) | +| 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index eee2af5..e99661f 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -26,7 +26,7 @@ возврат в `INITIATED`, `TRANSITION_NOT_ALLOWED`, `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`. - [x] 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`. Тесты: сценарии «Периметр версии». +- [x] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии». ## 3. Привязка документов и чек-лист -- 2.47.2 From 36bc966bf6d32d0187a1f86b6b00a0975f2d3f5b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 04:57:15 +0400 Subject: [PATCH 10/27] =?UTF-8?q?feat(consolidation):=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=D0=BC=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8,=20=D0=BF=D0=BE=D0=B4=D1=85=D0=B2=D0=B0=D1=82=20=D0=B8?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consolidation_documents: чистая fits (D-5), bind_confirmed (подхват, самый поздний confirmed_at на ключ R-1), unbind_entity, on_document_confirmed (привязка ко всем подходящим версиям «в работе», переход в DATA_CORRECTION через transition), on_documents_superseded (отвязка документов заменённых файлов). - DocumentService.confirm вызывает хук привязки; supersede_previous снимает привязки и пишет FILE_REPLACE.after_value.unbound_version_ids; create и PUT …/members подхватывают по-настоящему. - R-2: advisory-блокировка привязок ГК (lock_group_bindings) в create и lock_versions_for_entity закрывает гонку «создание версии ∥ подтверждение/автозамена». - R-3: lock_document и lock_file_documents — FOR NO KEY UPDATE (совместимо с FOR KEY SHARE вставки привязки). - Тесты: таблица fits, сценарии documents-api и primary-files, две версии одного документа, гонки на двух сессиях (confirm ∥ members в обе стороны, create ∥ confirm в обе стороны, цикл блокировок документа и версии). - design.md: решения №30–№36; plan и tasks.md (3.1) отмечены. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/consolidations.py | 21 +- api/src/repositories/files.py | 18 +- api/src/services/consolidation_documents.py | 248 ++++++- api/src/services/consolidations.py | 13 +- api/src/services/documents.py | 15 +- api/src/services/result_mapping.py | 25 +- api/tests/test_api_consolidation_members.py | 67 +- api/tests/test_consolidation_documents.py | 681 ++++++++++++++++++ api/tests/test_result_mapping.py | 121 +++- .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 7 + .../changes/slice-4-consolidation/tasks.md | 2 +- 12 files changed, 1174 insertions(+), 50 deletions(-) create mode 100644 api/tests/test_consolidation_documents.py diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index daa2527..2ccfa13 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -2,7 +2,8 @@ Функции не делают `commit` и, кроме `get_version`, `get_consolidation_for_group` и `list_periods` (они получают `VisibilityScope` и скрывают ГК чужого филиала), не применяют policy: проверку видимости делает вызывающий сервис. -Порядок блокировок строк — файл → документ → версии; `lock_versions_for_entity` берёт версии пачкой по `id`. +Порядок блокировок — файл → документ → advisory ГК (`lock_group_bindings`) → версии; `lock_versions_for_entity` берёт +advisory ГК ЮЛ и версии пачкой по `id`. """ import uuid @@ -29,6 +30,7 @@ from src.models.files import Document, StoredFile from src.models.osv import OsvEntry FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) +GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2) SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM) @@ -96,11 +98,23 @@ async def lock_version(session: AsyncSession, version_id: uuid.UUID) -> Consolid return (await session.execute(stmt)).scalar_one_or_none() +async def lock_group_bindings(session: AsyncSession, group_id: uuid.UUID) -> None: + """`pg_advisory_xact_lock` привязок документов ГК (R-2, design №30): сериализует создание версии консолидации ГК с + подтверждением и автозаменой документов ЮЛ этой ГК. Новая версия невидима другим транзакциям до commit, поэтому + `FOR UPDATE` версий её не ловит: создание берёт этот lock до чтения подтверждённых документов, а + `lock_versions_for_entity` — до блокировки версий. Снимается в конце транзакции.""" + key = func.hashtextextended(f"{GROUP_BINDINGS_LOCK_PREFIX}:{group_id}", 0) + await session.execute(select(func.pg_advisory_xact_lock(key))) + + async def lock_versions_for_entity( session: AsyncSession, entity_id: uuid.UUID, statuses: Collection[ConsolidationStatus] ) -> list[ConsolidationVersion]: """Версии со статусом из `statuses`, где ЮЛ — член периметра; блокируются `FOR UPDATE ORDER BY id` (R-2). + Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; ЮЛ без ГК — без неё: членом версии оно + быть не может): иначе версия, создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там. + Блокируется НАДМНОЖЕСТВО: версии этих статусов, где ЮЛ — член ИЛИ `consolidation.company_group_id` равен ГК ЮЛ (`legal_entity.company_group_id`). Иначе подтверждение документа ∥ `PUT …/members` теряет привязку: подтверждение не видит ещё не зафиксированного нового члена и не ждёт версию, которую правит периметр. Блокировка версии ГК, @@ -113,6 +127,11 @@ async def lock_versions_for_entity( ConsolidationMember.consolidation_version_id == ConsolidationVersion.id, ConsolidationMember.legal_entity_id == entity_id, ) + group_id = ( + await session.execute(select(LegalEntity.company_group_id).where(LegalEntity.id == entity_id)) + ).scalar_one_or_none() + if group_id is not None: + await lock_group_bindings(session, group_id) entity_group = select(LegalEntity.company_group_id).where(LegalEntity.id == entity_id).scalar_subquery() superset = ( select(ConsolidationVersion.id) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index eaf81a1..07fd79c 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -123,9 +123,16 @@ async def lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | N async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Document | None: - """`SELECT document … FOR UPDATE` с перечитыванием; вызывать после `lock_file` его файла (порядок блокировок).""" + """`SELECT document … FOR NO KEY UPDATE` с перечитыванием; вызывать после `lock_file` его файла. + + R-3 (design №13): `FOR NO KEY UPDATE`, а не `FOR UPDATE` — другие писатели документа ждут так же, но вставка + `consolidation_document` (FK берёт `FOR KEY SHARE` документа) не ждёт: иначе подтверждение (держит документ, ждёт + версию) и подхват (держит версию, вставляет привязку) взаимоблокировались бы.""" stmt = ( - select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True) + select(Document) + .where(Document.id == document_id) + .with_for_update(key_share=True) + .execution_options(populate_existing=True) ) return (await session.execute(stmt)).scalar_one_or_none() @@ -144,12 +151,15 @@ async def lock_jobs(session: AsyncSession, file_id: uuid.UUID) -> list[Recogniti async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list[Document]: - """`SELECT document … FOR UPDATE` всех документов файла (по `id`) с перечитыванием; после `lock_file`.""" + """`SELECT document … FOR NO KEY UPDATE` всех документов файла (по `id`) с перечитыванием; после `lock_file`. + + Режим — как у `lock_document` (R-3): совместим с `FOR KEY SHARE` вставки привязки; `DELETE` документа (удаление + файла) сам повышает блокировку до `FOR UPDATE`.""" stmt = ( select(Document) .where(Document.stored_file_id == file_id) .order_by(Document.id) - .with_for_update() + .with_for_update(key_share=True) .execution_options(populate_existing=True) ) return list((await session.execute(stmt)).scalars().all()) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 63f7d23..8c10ba8 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -1,39 +1,177 @@ -"""Привязка подтверждённых документов к версии консолидации (spec documents-api «Подхват документов»; design D-5). +"""Привязка подтверждённых документов к версиям консолидации (spec documents-api «Подтверждение документа», «Подхват +документов при создании версии и расширении периметра»; primary-files «Автозамена прежних версий»; audit-trail +`CONSOLIDATION_STATUS_CHANGE`, `FILE_REPLACE.unbound_version_ids`; design D-5, D-6, D-9; рулинги R-1, R-2, R-3). -ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор -подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок -файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызовы из -`services.consolidations.ConsolidationService` (`create`, `set_members`) сохраняет. +Точки привязки: подтверждение документа (`on_document_confirmed` — ко всем подходящим версиям «в работе», с переходом +`INITIATED`/`DOCUMENTS_PROCESSING → DATA_CORRECTION`), создание версии и добавление ЮЛ в периметр (`bind_confirmed`, +статус не меняется). Точки отвязки: автозамена файла (`on_documents_superseded`), удаление ЮЛ из периметра +(`unbind_entity`). Версии вне «в работе» не трогаются. -`unbind_entity` (Task 5) — прямое удаление привязок ЮЛ к версии; Task 6 владеет функцией и вправе переписать тело -(имя и сигнатура — прежние): вызывается из `set_members` для ЮЛ, ушедших из периметра. +Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` +привязан один документ — с самым поздним `confirmed_at`; `cumulative = is_cumulative is not False` (NULL — ББ/ОФР — +нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют. + +Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` +(`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — +advisory ГК первой; правка периметра — версия первой, advisory не берёт. Вставка привязки берёт на документ +`FOR KEY SHARE`, с `FOR NO KEY UPDATE` подтверждения она совместима. """ +import datetime import uuid -from collections.abc import Sequence +from collections.abc import Collection, Iterable, Sequence +from typing import Any from sqlalchemy import delete, select +from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession -from src.models.consolidation import ConsolidationDocument, ConsolidationVersion -from src.models.files import Document +from src.domain.policy import IN_WORK_STATUSES +from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion +from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document, StoredFile +from src.repositories import consolidations as repo +from src.services.audit import AuditService + +__all__ = ["bind_confirmed", "fits", "on_document_confirmed", "on_documents_superseded", "unbind_entity"] + +#: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6). +MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING}) +_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(PeriodQuarter)} + +BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool] + + +def _rowcount(result: Any) -> int: + """Число строк DML (как `services.file_status`): у `CursorResult` — `rowcount`, иначе 0.""" + return result.rowcount if isinstance(result, CursorResult) else 0 + + +def _cumulative(document: Document) -> bool: + """Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда за период с начала года, решение №10).""" + return document.is_cumulative is not False + + +def _key(document: Document) -> BindingKey: + """Ключ замены привязки (R-1): ЮЛ, тип, счёт, квартал и признак нарастающего.""" + return ( + document.legal_entity_id, + document.document_type, + document.osv_account_code, + document.period_quarter, + _cumulative(document), + ) + + +def _latest_first(document: Document) -> tuple[datetime.datetime, uuid.UUID]: + """Порядок «самый поздний `confirmed_at` первым»; равные — по `id` (детерминированно).""" + confirmed_at = document.confirmed_at or datetime.datetime.min.replace(tzinfo=datetime.UTC) + return confirmed_at, document.id + + +def fits( + document: Document, + version: ConsolidationVersion, # noqa: ARG001 - сигнатура D-5: подходящесть — функция документа и версии + consolidation: Consolidation, + member_ids: Collection[uuid.UUID], +) -> bool: + """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или + NULL) — квартал версии; квартальный — не позже квартала версии. Статус документа и файла не проверяется — это + дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" + if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: + return False + if document.period_quarter is None: + return False + if _cumulative(document): + return document.period_quarter == consolidation.quarter + return _QUARTER_ORDER[document.period_quarter] <= _QUARTER_ORDER[consolidation.quarter] + + +async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationMember.legal_entity_id).where(ConsolidationMember.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _bound_documents( + session: AsyncSession, version_id: uuid.UUID, entity_ids: Iterable[uuid.UUID] +) -> list[Document]: + """Документы ЮЛ `entity_ids`, уже привязанные к версии.""" + stmt = ( + select(Document) + .join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id) + .where( + ConsolidationDocument.consolidation_version_id == version_id, + Document.legal_entity_id.in_(list(entity_ids)), + ) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _unbind(session: AsyncSession, version_id: uuid.UUID, document_ids: Collection[uuid.UUID]) -> int: + if not document_ids: + return 0 + result = await session.execute( + delete(ConsolidationDocument).where( + ConsolidationDocument.consolidation_version_id == version_id, + ConsolidationDocument.document_id.in_(list(document_ids)), + ) + ) + return _rowcount(result) + + +async def _bind(session: AsyncSession, version_id: uuid.UUID, document: Document) -> None: + """Привязывает `document` к версии, предварительно отвязав привязанные документы с тем же ключом (R-1).""" + bound = await _bound_documents(session, version_id, [document.legal_entity_id]) + await _unbind(session, version_id, [other.id for other in bound if _key(other) == _key(document)]) + session.add(ConsolidationDocument(consolidation_version_id=version_id, document_id=document.id)) + await session.flush() async def bind_confirmed( - session: AsyncSession, # noqa: ARG001 - сигнатура окончательная, тело — заглушка до Task 6 - version: ConsolidationVersion, # noqa: ARG001 - entity_ids: Sequence[uuid.UUID], # noqa: ARG001 + session: AsyncSession, + *, + version: ConsolidationVersion, + consolidation: Consolidation, + entity_ids: Sequence[uuid.UUID], ) -> int: - """Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок.""" - return 0 + """Подхват (spec «Подхват документов…»): привязывает к версии `CONFIRMED` документы ЮЛ `entity_ids` из файлов не + `SUPERSEDED`, подходящие версии (`fits`); на ключ (R-1) — документ с самым поздним `confirmed_at`. Привязанный + документ того же ключа заменяется; уже привязанный победитель не трогается. Статус версии не меняется. Возвращает + число новых привязок. + + Версия должна быть под `FOR UPDATE` вызывающего (правка периметра) либо только что создана под advisory ГК + (`ConsolidationService.create`, R-2). + """ + if not entity_ids: + return 0 + members = await _member_ids(session, version.id) + stmt = ( + select(Document) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id.in_(list(entity_ids)), + Document.status == DocumentStatus.CONFIRMED, + Document.period_year == consolidation.year, + StoredFile.status != FileStatus.SUPERSEDED, + ) + ) + candidates = [doc for doc in (await session.execute(stmt)).scalars() if fits(doc, version, consolidation, members)] + winners: dict[BindingKey, Document] = {} + for document in sorted(candidates, key=_latest_first, reverse=True): + winners.setdefault(_key(document), document) + bound = await _bound_documents(session, version.id, entity_ids) + bound_ids = {document.id for document in bound} + replaced = [doc.id for doc in bound if _key(doc) in winners and winners[_key(doc)].id != doc.id] + await _unbind(session, version.id, replaced) + fresh = [document for document in winners.values() if document.id not in bound_ids] + session.add_all(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id) for doc in fresh) + await session.flush() + return len(fresh) async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_id: uuid.UUID) -> int: - """Снимает все привязки документов ЮЛ `entity_id` к версии `version_id`; возвращает число снятых. - - Task 5: простое прямое удаление `consolidation_document` по `document.legal_entity_id`. Принадлежит Task 6 - (отвязка по правилам D-5, порядок блокировок); версия должна быть под `FOR UPDATE` вызывающего. - """ + """Снимает все привязки документов ЮЛ `entity_id` к версии `version_id` (удаление ЮЛ из периметра); возвращает + число снятых. Статус версии не меняется; версия должна быть под `FOR UPDATE` вызывающего.""" owned = select(Document.id).where(Document.legal_entity_id == entity_id) result = await session.execute( delete(ConsolidationDocument).where( @@ -41,4 +179,72 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_ ConsolidationDocument.document_id.in_(owned), ) ) - return int(result.rowcount) # type: ignore[attr-defined] # CursorResult у DML; у базового Result атрибута нет + return _rowcount(result) + + +async def on_document_confirmed( + session: AsyncSession, + audit: AuditService, + *, + document: Document, + user_id: uuid.UUID, + now: datetime.datetime, +) -> list[uuid.UUID]: + """Хук подтверждения (spec «Подтверждение документа»): привязывает уже `CONFIRMED` документ ко всем версиям «в + работе», которым он подходит, заменяя привязанный документ того же ключа (R-1); версия из `INITIATED` / + `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` (`changed_by = user_id`, D-6). Возвращает id версий, + получивших привязку (по возрастанию). + + Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК + (создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2). + Блокировка версии (`consolidation_lock`) не требуется. + """ + # цикл импортов: consolidation_status → consolidations → consolidation_documents + from src.services.consolidation_status import transition # noqa: PLC0415 + + versions = await repo.lock_versions_for_entity(session, document.legal_entity_id, IN_WORK_STATUSES) + bound: list[uuid.UUID] = [] + for version in versions: + consolidation = await session.get(Consolidation, version.consolidation_id) + if consolidation is None: # FK гарантирует наличие + continue + if not fits(document, version, consolidation, await _member_ids(session, version.id)): + continue + await _bind(session, version.id, document) + if version.status in MOVES_TO_CORRECTION: + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DATA_CORRECTION, + changed_by=user_id, + comment=None, + now=now, + ) + bound.append(version.id) + return bound + + +async def on_documents_superseded( + session: AsyncSession, + *, + documents: Sequence[Document], + now: datetime.datetime, # noqa: ARG001 - сигнатура фиксирована планом (Task 7/8); статус версии не меняется +) -> list[uuid.UUID]: + """Хук автозамены (primary-files «Автозамена прежних версий»): снимает привязки `documents` (документы заменённого + файла) ко всем версиям «в работе»; версии других статусов и статус версии не трогаются. Возвращает id версий, где + привязки сняты (`unbound_version_ids`, по возрастанию). + + Вызывать после блокировки старых файлов: версии блокирует `repo.lock_versions_for_entity` по ЮЛ документов (R-2: + подхват, ждавший нас, уже закоммитил привязку — она видна и снимается). + """ + document_ids = [document.id for document in documents] + if not document_ids: + return [] + unbound: set[uuid.UUID] = set() + for entity_id in sorted({document.legal_entity_id for document in documents}): + versions = await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES) + for version in versions: + if await _unbind(session, version.id, document_ids): + unbound.add(version.id) + return sorted(unbound) diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 1deca69..aa75be9 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -87,10 +87,15 @@ class ConsolidationService: Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422 `RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё. + После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не + разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК. """ group = await directories_repo.get_company_group(self._session, self._scope, data.company_group_id) if group is None: raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + # R-2 (design №30): до чтения подтверждённых документов — подтверждение и автозамена документов ЮЛ ГК ждут нас + # (новая версия им невидима до commit), а мы — их + await repo.lock_group_bindings(self._session, group.id) related = await directories_repo.get_related_group(self._session, self._scope, data.related_group_id) if related is None: raise NotFoundError(RELATED_GROUP_NOT_FOUND) @@ -199,7 +204,9 @@ class ConsolidationService: for entity_id in removed: await consolidation_documents.unbind_entity(self._session, version_id=version.id, entity_id=entity_id) if added: - await consolidation_documents.bind_confirmed(self._session, version, added) + await consolidation_documents.bind_confirmed( + self._session, version=version, consolidation=consolidation, entity_ids=added + ) await self._audit.record( action=AuditAction.MEMBERSHIP_CHANGE, section=AuditSection.CONSOLIDATION, @@ -255,7 +262,9 @@ class ConsolidationService: ) ) await self._session.flush() - await consolidation_documents.bind_confirmed(self._session, version, [entity.id for entity in entities]) + await consolidation_documents.bind_confirmed( + self._session, version=version, consolidation=consolidation, entity_ids=[entity.id for entity in entities] + ) return consolidation, version @staticmethod diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 672dc16..15fcfce 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -4,9 +4,10 @@ Видимость — через ЮЛ-владельца (`repositories.files`); документ вне видимости — тот же 404, что несуществующий. Подтверждение и автозамена (`services.result_mapping.supersede_previous`) могут идти одновременно. Порядок блокировок -подтверждения — сначала `stored_file … FOR UPDATE`, затем `document … FOR UPDATE`: автозамена блокирует файлы (новый, -затем старые по id) и документы не блокирует, поэтому взаимной блокировки нет. Файл и документ перечитываются под -блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). +подтверждения — `stored_file … FOR UPDATE` → `document … FOR NO KEY UPDATE` (R-3) → advisory ГК → версии консолидации +«в работе» по `id` (`consolidation_documents.on_document_confirmed`, D-9, R-2): автозамена блокирует файлы (новый, затем +старые по id), затем advisory ГК и версии, документы не блокирует, поэтому взаимной блокировки нет. Файл и документ +перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). """ import uuid @@ -25,6 +26,7 @@ from src.models.osv import OsvEntry from src.repositories import files as files_repo from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief +from src.services import consolidation_documents from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего @@ -148,7 +150,9 @@ class DocumentService: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — - `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. + `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции + документ привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят + в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) file = await files_repo.lock_file(self._session, visible.stored_file_id) @@ -173,6 +177,9 @@ class DocumentService: document.confirmed_at = now document.updated_at = now await self._session.flush() + await consolidation_documents.on_document_confirmed( + self._session, self._audit.service, document=document, user_id=self._audit.user_id, now=now + ) await self._audit.record( action=AuditAction.DOCUMENT_CONFIRM, section=AuditSection.PRIMARY_DOCUMENTS, diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 1ec0abe..6e4df05 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -65,6 +65,7 @@ from src.models.enums import ( from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.repositories.counterparties import CounterpartyIndex +from src.services import consolidation_documents from src.services.audit import AuditService from src.services.file_status import set_file_status_for_job @@ -472,9 +473,14 @@ async def supersede_previous( `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы; в транзакции вызывающего, без commit. + Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются + (`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события + заменённого файла (spec audit-trail), статус версий не меняется. + Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в - порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`). + порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем + advisory ГК и версии консолидации по `id` (D-9, R-2). """ keys = {key for key in map(_document_key, new_documents) if key is not None} if not keys: @@ -528,6 +534,17 @@ async def supersede_previous( new_file.version_no = max(old.version_no for old in locked) + 1 new_file.updated_at = now await session.flush() + # привязки к версиям «в работе» снимаются у ВСЕХ документов заменённого файла (spec primary-files), а не только у + # совпавших по ключу: подхват берёт документы лишь незаменённых файлов (design №33) + file_documents = await session.execute(select(Document).where(Document.stored_file_id.in_(ids))) + documents_by_file: dict[uuid.UUID, list[Document]] = {} + for document in file_documents.scalars(): + documents_by_file.setdefault(document.stored_file_id, []).append(document) + unbound: dict[uuid.UUID, list[uuid.UUID]] = {} + for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9) + unbound[old.id] = await consolidation_documents.on_documents_superseded( + session, documents=documents_by_file.get(old.id, []), now=now + ) audit = AuditService(session) uploader = f"user:{uploader_id}" if uploader_id is not None else "неизвестен" @@ -541,7 +558,11 @@ async def supersede_previous( object_type=STORED_FILE_OBJECT_TYPE, object_id=old.id, user_id=None, - after_value={"file_id": new_file.id, "version_no": new_file.version_no}, + after_value={ + "file_id": new_file.id, + "version_no": new_file.version_no, + "unbound_version_ids": unbound[old.id], + }, description=( f"заменён файлом {new_file.original_name} (загрузил {uploader}); " f"документы: {', '.join(map(_describe_document, documents))}; " diff --git a/api/tests/test_api_consolidation_members.py b/api/tests/test_api_consolidation_members.py index c258bf8..8c887a2 100644 --- a/api/tests/test_api_consolidation_members.py +++ b/api/tests/test_api_consolidation_members.py @@ -1,9 +1,9 @@ """Периметр версии: `PUT /consolidations/versions/{id}/members` (spec consolidations-api «Периметр версии»; directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-5, D-8, D-9). -Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`) до Task 6 — заглушка: здесь проверяется ВЫЗОВ -(шпион на функцию модуля), а настоящие привязки появятся с реализацией и ячейкой `CONFIRMED` (Task 6, Task 8). -Отвязка документов удалённых ЮЛ (`unbind_entity`) работает уже сейчас — прямое удаление `consolidation_document`. +Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`): шпион проверяет, для кого он вызван, а +`test_add_member_really_binds_confirmed_documents` — настоящую привязку (Task 6; ячейка `CONFIRMED` — Task 8). +Отвязка документов удалённых ЮЛ — `unbind_entity`. """ import uuid @@ -16,7 +16,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.models.access import AppUser from src.models.audit_ex import AuditEvent -from src.models.consolidation import ConsolidationDocument, ConsolidationMember, ConsolidationVersion +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationMember, + ConsolidationVersion, +) from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroupMember from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter from src.services import consolidation_documents @@ -125,8 +130,14 @@ async def test_add_member_updates_perimeter_and_picks_up_documents( ) calls: list[tuple[uuid.UUID, list[uuid.UUID]]] = [] - async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int: - calls.append((bound_version.id, list(entity_ids))) + async def spy( + session: AsyncSession, + *, + version: ConsolidationVersion, + consolidation: Consolidation, + entity_ids: Sequence[uuid.UUID], + ) -> int: + calls.append((version.id, list(entity_ids))) return 0 monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy) @@ -142,6 +153,40 @@ async def test_add_member_updates_perimeter_and_picks_up_documents( assert await _member_ids(seeded, version.id) == {e.id for e in [*inside, outside[0]]} +@pytest.mark.integration +async def test_add_member_really_binds_confirmed_documents( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Добавленное ЮЛ с подтверждённой ОСВ за квартал версии: `consolidation_document` создан без шпионов; статус + версии подхват не меняет; подтверждённая ОСВ другого года не подхватывается.""" + version, inside, outside, _ = await _version(seeded) + fitting = await make_confirmed_osv( + seeded, + entity=outside[0], + account=ACCOUNT, + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}], + ) + await make_confirmed_osv( + seeded, + entity=outside[0], + account=ACCOUNT, + year=2023, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}], + ) + client = await auth_client(KA_RF) + + response = await client.put(_url(version.id), json=_body([*inside, outside[0]])) + + assert response.status_code == HTTP_OK, response.text + assert await _bound_documents(seeded, version.id) == {fitting.id} + assert response.json()["status"] == ConsolidationStatus.DATA_CORRECTION.value + + @pytest.mark.integration async def test_unchanged_members_are_not_touched_by_picking_up( auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch @@ -150,7 +195,13 @@ async def test_unchanged_members_are_not_touched_by_picking_up( version, inside, _, _ = await _version(seeded, members=THREE, spare=0) calls: list[list[uuid.UUID]] = [] - async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int: + async def spy( + session: AsyncSession, + *, + version: ConsolidationVersion, + consolidation: Consolidation, + entity_ids: Sequence[uuid.UUID], + ) -> int: calls.append(list(entity_ids)) return 0 @@ -216,7 +267,7 @@ async def test_removing_member_drops_its_bindings_only(auth_client: AuthClientFa @pytest.mark.integration async def test_unbind_entity_removes_only_that_entitys_documents_of_that_version(seeded: AsyncSession) -> None: - """`unbind_entity` (Task 6 заменит тело, имя и сигнатура — прежние) возвращает число снятых привязок.""" + """`unbind_entity` возвращает число снятых привязок.""" version, inside, _, _ = await _version(seeded, locked_by=None) docs = [] for entity in inside: diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py new file mode 100644 index 0000000..66964ff --- /dev/null +++ b/api/tests/test_consolidation_documents.py @@ -0,0 +1,681 @@ +"""Привязка документов к версиям консолидации (spec documents-api «Подтверждение документа», «Подхват документов при +создании версии и расширении периметра»; audit-trail `CONSOLIDATION_STATUS_CHANGE`; design D-5, D-6, D-9; рулинги +R-1 — ключ замены с признаком нарастающего, R-2 — гонки потерянной привязки, R-3 — `FOR NO KEY UPDATE` документа). + +Сценарии подтверждения и подхвата — поверх `seeded`/`auth_client` (транзакция теста). Гонки — на `committed_*` +(настоящие параллельные транзакции, таймаут 5 с): подтверждение ∥ правка периметра в обе стороны, создание версии ∥ +подтверждение в обе стороны и совместимость блокировки документа подтверждения со вставкой привязки. +""" + +import asyncio +import datetime +import uuid +from collections.abc import Sequence +from typing import Any + +import pytest +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.common.clock import utc_now +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ( + Consolidation, + ConsolidationDocument, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup +from src.models.enums import ( + AuditAction, + ConsolidationStatus, + DocumentStatus, + DocumentType, + FileStatus, + PeriodQuarter, + RecognitionStatus, +) +from src.models.files import Document +from src.repositories import consolidations as repo +from src.repositories import files as files_repo +from src.services import consolidation_documents +from src.services.audit import AuditService +from src.services.consolidation_documents import fits +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_document, + make_legal_entity, + make_lock, + make_membership, + make_related_group, + make_stored_file, +) + +HTTP_OK = 200 +HTTP_CREATED = 201 +ROSTOV = "Ростовский региональный филиал" +KA_RF = "ka_rf@armka.local" +ACCOUNT = "60" +YEAR = 2024 +LOCK_WAIT_SECONDS = 5 +ROWS = [{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}] +Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 + + +# --- fits: чистая функция (D-5) -------------------------------------------------------------------------------------- + +_MEMBER = uuid.uuid4() +_STRANGER = uuid.uuid4() + + +def _pure_document( + *, entity: uuid.UUID = _MEMBER, year: int = YEAR, quarter: PeriodQuarter | None = Q3, cumulative: bool | None +) -> Document: + return Document( + legal_entity_id=entity, + document_type=DocumentType.OSV, + osv_account_code=ACCOUNT, + period_year=year, + period_quarter=quarter, + is_cumulative=cumulative, + status=DocumentStatus.CONFIRMED, + ) + + +@pytest.mark.parametrize( + ("document", "expected"), + [ + (_pure_document(cumulative=False, quarter=Q1), True), # член, квартальный Q1 ≤ Q3 + (_pure_document(entity=_STRANGER, cumulative=True), False), # не член периметра + (_pure_document(year=YEAR - 1, cumulative=True), False), # другой год + (_pure_document(cumulative=True, quarter=Q3), True), # нарастающий за квартал версии + (_pure_document(cumulative=True, quarter=Q2), False), # нарастающий за Q2 при версии Q3 (Review Focus 2) + (_pure_document(cumulative=False, quarter=Q4), False), # квартальный позже квартала версии + (_pure_document(cumulative=False, quarter=None), False), # квартал не определён + (_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10) + (_pure_document(cumulative=None, quarter=Q2), False), # NULL за Q2 при версии Q3 — как нарастающий + ], +) +def test_fits(document: Document, expected: bool) -> None: + version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) + consolidation = Consolidation(year=YEAR, quarter=Q3) + + assert fits(document, version, consolidation, {_MEMBER}) is expected + + +# --- данные --------------------------------------------------------------------------------------------------------- + + +async def _user(session: AsyncSession, email: str = KA_RF) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _group(session: AsyncSession, members: int = 2) -> tuple[CompanyGroup, list[LegalEntity]]: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)] + return group, entities + + +async def _version( + session: AsyncSession, + group: CompanyGroup, + members: Sequence[LegalEntity], + *, + quarter: PeriodQuarter = Q3, + status: ConsolidationStatus = ConsolidationStatus.INITIATED, +) -> ConsolidationVersion: + _, version = await make_consolidation( + session, group=group, year=YEAR, quarter=quarter, members=members, initiator=await _user(session), status=status + ) + return version + + +async def _document( # noqa: PLR0913 - тестовая фабрика: ЮЛ, счёт, период и статусы документа и файла + session: AsyncSession, + entity: LegalEntity, + *, + quarter: PeriodQuarter = Q2, + cumulative: bool = False, + account: str = ACCOUNT, + status: DocumentStatus = DocumentStatus.NEEDS_CHECK, +) -> Document: + """Документ ОСВ файла `PROCESSED` (по умолчанию — квартальный `NEEDS_CHECK` за 2024 Q2).""" + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + return await make_document( + session, + file=file, + job=job, + account=account, + year=YEAR, + quarter=quarter, + status=status, + is_cumulative=cumulative, + ) + + +async def _confirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ, период, счёт и время подтверждения + session: AsyncSession, + entity: LegalEntity, + *, + quarter: PeriodQuarter, + cumulative: bool = False, + account: str = ACCOUNT, + confirmed_at: datetime.datetime | None = None, +) -> Document: + document = await make_confirmed_osv( + session, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=cumulative, rows=ROWS + ) + if confirmed_at is not None: + document.confirmed_at = confirmed_at + await session.flush() + return document + + +async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: + stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id) + return set((await session.execute(stmt)).scalars()) + + +async def _status(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationStatus: + stmt = select(ConsolidationVersion.status).where(ConsolidationVersion.id == version_id) + return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one() + + +async def _history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]: + return await repo.history(session, version_id) + + +async def _bind(session: AsyncSession, version: ConsolidationVersion, document: Document) -> None: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + + +# --- подтверждение ---------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_confirm_binds_document_and_moves_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Привязка к версии и переход статуса»: квартальная ОСВ 60 за Q2 при версии Q3 в `INITIATED`.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0]) + user = await _user(seeded) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {document.id} + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + last = (await _history(seeded, version.id))[-1] + assert (last.from_status, last.to_status, last.changed_by) == ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DATA_CORRECTION, + user.id, + ) + [event] = ( + await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE)) + ).scalars() + assert (event.user_id, event.consolidation_version_id, event.object_id) == (user.id, version.id, version.id) + assert (event.before_value, event.after_value) == ({"status": "INITIATED"}, {"status": "DATA_CORRECTION"}) + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("status", "moved"), + [ + (ConsolidationStatus.DOCUMENTS_PROCESSING, True), + (ConsolidationStatus.DATA_CORRECTION, False), # уже там: привязка без перехода + ], +) +async def test_confirm_binds_in_other_in_work_statuses( + seeded: AsyncSession, auth_client: AuthClientFactory, status: ConsolidationStatus, moved: bool +) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=status) + before = len(await _history(seeded, version.id)) + document = await _document(seeded, entities[0], quarter=Q3, cumulative=True) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {document.id} + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + assert len(await _history(seeded, version.id)) == before + (1 if moved else 0) + + +@pytest.mark.integration +async def test_confirm_does_not_bind_cumulative_of_earlier_quarter( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """«Не подходит по периоду»: нарастающая ОСВ 60 за Q2 при версии Q3 — привязки нет, статус прежний.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0], quarter=Q2, cumulative=True) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == set() + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + assert len(await _history(seeded, version.id)) == 1 + + +@pytest.mark.integration +async def test_confirm_does_not_bind_cancelled_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Версия в CANCELLED»: подходящий документ не привязывается.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.CANCELLED) + document = await _document(seeded, entities[0]) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == set() + assert await _status(seeded, version.id) is ConsolidationStatus.CANCELLED + + +@pytest.mark.integration +async def test_confirm_does_not_bind_entity_outside_perimeter( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """ЮЛ той же ГК, но вне периметра версии: привязки нет (блокируется надмножество, привязывается только член).""" + group, entities = await _group(seeded, members=3) + version = await _version(seeded, group, entities[:2]) + document = await _document(seeded, entities[2]) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == set() + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + + +@pytest.mark.integration +async def test_confirm_replaces_bound_document_with_same_key( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Привязанный документ того же ЮЛ с той же тройкой и тем же признаком нарастающего отвязывается (D-5); нарастающий + той же пары (ЮЛ, счёт) за тот же квартал остаётся (R-1); документ другого ЮЛ не трогается.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DATA_CORRECTION) + old_quarterly = await _confirmed(seeded, entities[0], quarter=Q3) + cumulative = await _confirmed(seeded, entities[0], quarter=Q3, cumulative=True) + other_entity = await _confirmed(seeded, entities[1], quarter=Q3) + for document in (old_quarterly, cumulative, other_entity): + await _bind(seeded, version, document) + new_quarterly = await _document(seeded, entities[0], quarter=Q3) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{new_quarterly.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {new_quarterly.id, cumulative.id, other_entity.id} + + +@pytest.mark.integration +async def test_one_document_binds_to_two_versions(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """Review Focus 1: консолидации ГК за Q2 и Q3 одного года; квартальная ОСВ Q1 подходит обеим — подтверждение + привязывает её к обеим и переводит обе в `DATA_CORRECTION`.""" + group, entities = await _group(seeded) + q2 = await _version(seeded, group, entities, quarter=Q2) + q3 = await _version(seeded, group, entities, quarter=Q3) + document = await _document(seeded, entities[0], quarter=Q1) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + for version in (q2, q3): + assert await _bound(seeded, version.id) == {document.id} + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + events = ( + await seeded.execute( + select(AuditEvent.consolidation_version_id).where( + AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE + ) + ) + ).scalars() + assert sorted(events) == sorted([q2.id, q3.id]) + + +@pytest.mark.integration +async def test_on_document_confirmed_returns_bound_versions(seeded: AsyncSession) -> None: + """Хук возвращает id версий, получивших привязку (по возрастанию `id`); неподходящие и аннулированные — нет.""" + group, entities = await _group(seeded) + q2 = await _version(seeded, group, entities, quarter=Q2) + q3 = await _version(seeded, group, entities, quarter=Q3) + q1 = await _version(seeded, group, entities, quarter=Q1) # квартальный Q2 не подходит версии Q1 + q4 = await _version(seeded, group, entities, quarter=Q4, status=ConsolidationStatus.CANCELLED) + document = await _document(seeded, entities[0], quarter=Q2, status=DocumentStatus.CONFIRMED) + user = await _user(seeded) + + bound = await consolidation_documents.on_document_confirmed( + seeded, AuditService(seeded), document=document, user_id=user.id, now=utc_now() + ) + + assert bound == sorted([q2.id, q3.id]) + assert await _bound(seeded, q1.id) == set() + assert await _bound(seeded, q4.id) == set() + + +# --- подхват ---------------------------------------------------------------------------------------------------------- + + +async def _gsk(session: AsyncSession, group: CompanyGroup, entities: Sequence[LegalEntity]) -> RelatedGroup: + gsk = await make_related_group(session, group=group, name=f"ГСК {uuid.uuid4().hex[:6]}") + for entity in entities: + await make_membership(session, related_group=gsk, entity=entity) + return gsk + + +def _create_body(group: CompanyGroup, gsk: RelatedGroup, quarter: str = "Q3") -> dict[str, Any]: + return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": YEAR, "quarter": quarter} + + +@pytest.mark.integration +async def test_create_picks_up_documents_confirmed_before(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Фикстуры, подтверждённые до консолидации»: ОСВ 60 за Q1, Q2, Q3 подтверждены, затем создана консолидация + за Q3 — три привязки, версия в `INITIATED` (ячейка чек-листа — Task 8).""" + group, entities = await _group(seeded) + gsk = await _gsk(seeded, group, entities) + documents = [await _confirmed(seeded, entities[0], quarter=quarter) for quarter in (Q1, Q2, Q3)] + client = await auth_client(KA_RF) + + response = await client.post("/consolidations", json=_create_body(group, gsk)) + + assert response.status_code == HTTP_CREATED, response.text + version_id = uuid.UUID(response.json()["id"]) + assert await _bound(seeded, version_id) == {document.id for document in documents} + assert await _status(seeded, version_id) is ConsolidationStatus.INITIATED + assert len(await _history(seeded, version_id)) == 1 + + +@pytest.mark.integration +async def test_create_picks_latest_confirmed_of_same_key(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """«Две подтверждённые версии одного документа»: два незаменённых файла с `CONFIRMED` ОСВ 62 за Q3 — привязан + документ с более поздним `confirmed_at`.""" + group, entities = await _group(seeded) + gsk = await _gsk(seeded, group, entities) + now = utc_now() + await _confirmed(seeded, entities[0], quarter=Q3, account="62", confirmed_at=now - datetime.timedelta(hours=1)) + later = await _confirmed(seeded, entities[0], quarter=Q3, account="62", confirmed_at=now) + client = await auth_client(KA_RF) + + response = await client.post("/consolidations", json=_create_body(group, gsk)) + + assert response.status_code == HTTP_CREATED, response.text + assert await _bound(seeded, uuid.UUID(response.json()["id"])) == {later.id} + + +@pytest.mark.integration +async def test_bind_confirmed_filters_documents(seeded: AsyncSession) -> None: + """Подхват берёт только `CONFIRMED` документы незаменённых файлов, подходящие версии; квартальный и нарастающий + за один квартал — оба (R-1); статус версии не меняется; повторный подхват ничего не добавляет.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + quarterly = await _confirmed(seeded, entities[0], quarter=Q3) + cumulative = await _confirmed(seeded, entities[0], quarter=Q3, cumulative=True) + await _confirmed(seeded, entities[0], quarter=Q2, cumulative=True) # нарастающий за Q2 — не подходит + await _document(seeded, entities[0], quarter=Q1) # не подтверждён + superseded = await _confirmed(seeded, entities[0], quarter=Q1) + await seeded.execute( + text("UPDATE stored_file SET status = 'SUPERSEDED' WHERE id = :id"), {"id": superseded.stored_file_id} + ) + + count = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + + assert count == 2 # noqa: PLR2004 - квартальный и нарастающий Q3 + assert await _bound(seeded, version.id) == {quarterly.id, cumulative.id} + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + again = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + assert again == 0 + assert await _bound(seeded, version.id) == {quarterly.id, cumulative.id} + + +@pytest.mark.integration +async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSession) -> None: + """Существующая привязка той же тройки заменяется документом с более поздним `confirmed_at`.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + now = utc_now() + older = await _confirmed(seeded, entities[0], quarter=Q3, confirmed_at=now - datetime.timedelta(hours=1)) + newer = await _confirmed(seeded, entities[0], quarter=Q3, confirmed_at=now) + await _bind(seeded, version, older) + + count = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + + assert count == 1 + assert await _bound(seeded, version.id) == {newer.id} + + +# --- гонки (настоящие параллельные транзакции) ------------------------------------------------------------------------ + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + """Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки (строки или advisory).""" + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("запрос не встал в ожидание блокировки") + + +async def _committed_setup( + maker: async_sessionmaker[AsyncSession], *, members: int = 2, spare: int = 1, with_version: bool = True +) -> dict[str, Any]: + """ГК филиала: `members` ЮЛ в периметре версии 2024 Q3 (`INITIATED`, блокировка у ka_rf), `spare` ЮЛ ГК вне + периметра, ГСК из всех ЮЛ; документ `NEEDS_CHECK` квартальной ОСВ 60 за Q2 у первого запасного ЮЛ (или у + первого члена, если запасных нет).""" + async with maker() as session, session.begin(): + group, entities = await _group(session, members=members + spare) + gsk = await _gsk(session, group, entities) + version_id = None + if with_version: + version = await _version(session, group, entities[:members]) + await make_lock(session, version=version, user=await _user(session)) + version_id = version.id + owner = entities[members] if spare else entities[0] + document = await _document(session, owner) + return { + "group_id": group.id, + "gsk_id": gsk.id, + "entity_ids": [entity.id for entity in entities], + "version_id": version_id, + "document_id": document.id, + "file_id": document.stored_file_id, + "owner_id": owner.id, + "user_id": (await _user(session)).id, + } + + +async def _confirm_in(session: AsyncSession, data: dict[str, Any]) -> list[uuid.UUID]: + """Подтверждение, как в `DocumentService.confirm`: файл → документ → статус → хук привязки (транзакция открыта).""" + file = await files_repo.lock_file(session, data["file_id"]) + document = await files_repo.lock_document(session, data["document_id"]) + assert file is not None + assert document is not None + now = utc_now() + document.status = DocumentStatus.CONFIRMED + document.confirmed_by = data["user_id"] + document.confirmed_at = now + await session.flush() + return await consolidation_documents.on_document_confirmed( + session, AuditService(session), document=document, user_id=data["user_id"], now=now + ) + + +async def _committed_bound(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]: + async with maker() as session: + return await _bound(session, version_id) + + +@pytest.mark.integration +async def test_confirm_waits_for_member_edit_and_binds_new_member( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """R-2: правка периметра (версия `FOR UPDATE`, добавлен новый член) ∥ подтверждение документа нового члена. + + Подтверждение блокирует надмножество версий ГК, ждёт правку и после её commit видит нового члена — привязка не + теряется, версия в `DATA_CORRECTION`.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + client = await committed_client(KA_RF) + + async with maker() as editor, editor.begin(): + assert await repo.lock_version(editor, data["version_id"]) is not None + editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"])) + await editor.flush() + confirm = asyncio.create_task(client.post(f"/documents/{data['document_id']}/confirm")) + await _wait_for_lock_waiter(maker) + assert not confirm.done() + response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_OK, response.text + assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]} + async with maker() as session: + assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +async def test_member_edit_waits_for_confirm_and_picks_document_up( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """R-2/R-3, обратный порядок: подтверждение держит файл, документ и версии ГК; `PUT …/members`, добавляющий ЮЛ + документа, ждёт версию, после commit подтверждения подхватывает уже `CONFIRMED` документ. Взаимоблокировки нет.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + client = await committed_client(KA_RF) + body = {"legal_entity_ids": [str(entity_id) for entity_id in data["entity_ids"]]} + + async with maker() as confirmer, confirmer.begin(): + assert await _confirm_in(confirmer, data) == [] # ЮЛ ещё не член — привязывать не к чему + edit = asyncio.create_task(client.put(f"/consolidations/versions/{data['version_id']}/members", json=body)) + await _wait_for_lock_waiter(maker) + assert not edit.done() + response = await asyncio.wait_for(edit, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_OK, response.text + assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]} + async with maker() as session: # подхват статус не меняет + assert await _status(session, data["version_id"]) is ConsolidationStatus.INITIATED + + +@pytest.mark.integration +async def test_document_lock_does_not_block_binding_insert( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-3: подтверждение держит документ (`lock_document`) и затем ждёт версию; подхват держит версию и вставляет + привязку на этот документ (FK берёт `FOR KEY SHARE` документа). С `FOR UPDATE` документа это взаимоблокировка; + с `FOR NO KEY UPDATE` вставка проходит, обе транзакции завершаются за таймаут.""" + maker = committed_sessionmaker + data = await _committed_setup(maker, spare=0) + async with maker() as session, session.begin(): + document = await session.get(Document, data["document_id"]) + assert document is not None + document.status = DocumentStatus.CONFIRMED + document.confirmed_at = utc_now() + + version_held = asyncio.Event() + + async def binder() -> None: + async with maker() as session, session.begin(): + assert await repo.lock_version(session, data["version_id"]) is not None + version_held.set() + session.add( + ConsolidationDocument(consolidation_version_id=data["version_id"], document_id=data["document_id"]) + ) + await session.flush() # FK: FOR KEY SHARE документа, который держит «подтверждение» + + async with maker() as confirmer, confirmer.begin(): + assert await files_repo.lock_file(confirmer, data["file_id"]) is not None + assert await files_repo.lock_document(confirmer, data["document_id"]) is not None + binding = asyncio.create_task(binder()) + await asyncio.wait_for(version_held.wait(), LOCK_WAIT_SECONDS) + # «подтверждение» ждёт версию, которую держит подхват; с FOR UPDATE документа — взаимоблокировка + assert await asyncio.wait_for(repo.lock_version(confirmer, data["version_id"]), LOCK_WAIT_SECONDS) + await asyncio.wait_for(binding, LOCK_WAIT_SECONDS) + + assert await _committed_bound(maker, data["version_id"]) == {data["document_id"]} + + +@pytest.mark.integration +async def test_create_waits_for_confirm_and_picks_document_up( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """R-2, создание версии ∥ подтверждение: подтверждение держит advisory-блокировку ГК; `POST /consolidations` ждёт + её и после commit подхватывает уже `CONFIRMED` документ.""" + maker = committed_sessionmaker + data = await _committed_setup(maker, spare=0, with_version=False) + client = await committed_client(KA_RF) + body = {"company_group_id": str(data["group_id"]), "related_group_id": str(data["gsk_id"]), "year": YEAR} + + async with maker() as confirmer, confirmer.begin(): + assert await _confirm_in(confirmer, data) == [] + create = asyncio.create_task(client.post("/consolidations", json={**body, "quarter": "Q3"})) + await _wait_for_lock_waiter(maker) + assert not create.done() + response = await asyncio.wait_for(create, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_CREATED, response.text + assert await _committed_bound(maker, uuid.UUID(response.json()["id"])) == {data["document_id"]} + + +@pytest.mark.integration +async def test_confirm_waits_for_create_and_binds_new_version( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """R-2, подтверждение ∥ создание версии: создание держит advisory-блокировку ГК (новая версия до commit невидима); + подтверждение ждёт её и после commit привязывает документ к новой версии и двигает её статус.""" + maker = committed_sessionmaker + data = await _committed_setup(maker, spare=0, with_version=False) + client = await committed_client(KA_RF) + + async with maker() as creator, creator.begin(): + await repo.lock_group_bindings(creator, data["group_id"]) # как `ConsolidationService.create` + group = await creator.get(CompanyGroup, data["group_id"]) + assert group is not None + entities = [await creator.get(LegalEntity, entity_id) for entity_id in data["entity_ids"]] + version = await _version(creator, group, [entity for entity in entities if entity is not None]) + version_id = version.id + confirm = asyncio.create_task(client.post(f"/documents/{data['document_id']}/confirm")) + await _wait_for_lock_waiter(maker) + assert not confirm.done() + response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_OK, response.text + assert await _committed_bound(maker, version_id) == {data["document_id"]} + async with maker() as session: + assert await _status(session, version_id) is ConsolidationStatus.DATA_CORRECTION diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 47c87b1..9798bef 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -29,10 +29,12 @@ from src.core.config import Settings, get_settings from src.models.access import AppUser from src.models.accounting import ChartOfAccount from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, + ConsolidationStatus, CounterpartyMatchKind, DocumentCheckResult, DocumentStatus, @@ -59,7 +61,15 @@ from src.services.result_mapping import ( validate_result_key, ) from tests.conftest import FakeStorage -from tests.factories import make_document, make_legal_entity, make_result_object, make_stored_file +from tests.factories import ( + make_company_group, + make_consolidation, + make_document, + make_legal_entity, + make_result_object, + make_stored_file, + make_version, +) from tests.test_recognition_inbox import envelope, fake_msg API_DIR = Path(__file__).resolve().parents[1] @@ -915,7 +925,7 @@ async def test_new_osv_60_supersedes_previous_file( assert event.user_id is None assert event.section is AuditSection.PRIMARY_DOCUMENTS assert (event.object_type, event.object_id) == ("stored_file", old_id) - assert event.after_value == {"file_id": str(new_id), "version_no": 2} + assert event.after_value == {"file_id": str(new_id), "version_no": 2, "unbound_version_ids": []} assert event.description is not None assert f"user:{uploader}" in event.description assert f"OSV 60 2025 Q1 ({old_status.value})" in event.description @@ -1026,7 +1036,7 @@ async def test_version_chain(committed_sessionmaker: async_sessionmaker[AsyncSes assert v3.version_no == 3 # noqa: PLR2004 - третья версия [event] = await _replace_events(maker) assert event.object_id == v2_id - assert event.after_value == {"file_id": str(v3_id), "version_no": 3} + assert event.after_value == {"file_id": str(v3_id), "version_no": 3, "unbound_version_ids": []} async def test_one_event_per_replaced_file(committed_sessionmaker: async_sessionmaker[AsyncSession]): @@ -1096,7 +1106,110 @@ async def test_new_file_supersedes_several_files(committed_sessionmaker: async_s assert sorted(event.object_id for event in events) == ordered # type: ignore[type-var] for event in events: assert event.user_id is None - assert event.after_value == {"file_id": str(new_id), "version_no": expected_version} + assert event.after_value == { + "file_id": str(new_id), + "version_no": expected_version, + "unbound_version_ids": [], + } + + +async def test_replaced_document_is_unbound_from_versions_in_work( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """primary-files «Заменённый документ был в версии»: `CONFIRMED` ОСВ 60 за 2024 Q2 привязана к версии 2024 Q3 в + `DATA_CORRECTION` и к аннулированной версии; автозамена отвязывает документ только от версии «в работе» (статус + прежний), `FILE_REPLACE.after_value.unbound_version_ids` — эта версия. Привязка другого документа заменённого + файла (ОСВ 62, ключ замены не совпал) тоже снимается: заменён весь файл.""" + maker = committed_sessionmaker + async with maker() as session, session.begin(): + initiator = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one() + group = await make_company_group(session, name="ГК Автозамена") + entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000000", group=group) + other = await make_legal_entity(session, name="ООО Второе", group=group) + consolidation, in_work = await make_consolidation( + session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=[entity, other], + initiator=initiator, + status=ConsolidationStatus.DATA_CORRECTION, + ) + cancelled = await make_version( + session, + consolidation=consolidation, + version_no=2, + members=[entity, other], + initiator=initiator, + status=ConsolidationStatus.CANCELLED, + ) + old_file, old_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + old_60 = await make_document( + session, + file=old_file, + job=old_job, + account="60", + year=2024, + quarter=PeriodQuarter.Q2, + status=DocumentStatus.CONFIRMED, + is_cumulative=False, + ) + old_62 = await make_document( + session, + file=old_file, + job=old_job, + account="62", + year=2024, + quarter=PeriodQuarter.Q3, + status=DocumentStatus.CONFIRMED, + is_cumulative=True, + ) + for version in (in_work, cancelled): + for document in (old_60, old_62): + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + new_file, new_job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + new_60 = await make_document( + session, + file=new_file, + job=new_job, + account="60", + year=2024, + quarter=PeriodQuarter.Q2, + status=DocumentStatus.NEEDS_CHECK, + is_cumulative=False, + ) + ids = (in_work.id, cancelled.id, old_file.id, old_60.id, old_62.id) + + async with maker() as session, session.begin(): + new = await session.get(StoredFile, new_file.id) + assert new is not None + replaced = await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now()) + in_work_id, cancelled_id, old_file_id, old_60_id, old_62_id = ids + + assert [file.id for file in replaced] == [old_file_id] + async with maker() as session: + + def bound(version_id: uuid.UUID) -> Any: + return select(ConsolidationDocument.document_id).where( + ConsolidationDocument.consolidation_version_id == version_id + ) + + assert set((await session.execute(bound(in_work_id))).scalars()) == set() + assert set((await session.execute(bound(cancelled_id))).scalars()) == {old_60_id, old_62_id} + statuses = dict( + (await session.execute(select(ConsolidationVersion.id, ConsolidationVersion.status))).tuples().all() + ) + assert statuses == { + in_work_id: ConsolidationStatus.DATA_CORRECTION, + cancelled_id: ConsolidationStatus.CANCELLED, + } + [event] = await _replace_events(maker) + assert event.object_id == old_file_id + assert event.after_value == {"file_id": str(new_file.id), "version_no": 2, "unbound_version_ids": [str(in_work_id)]} # --- audit-trail: обмен и маппинг не пишут audit_event (ASSUMPTION(OQ-55)) ---------------------------------------- diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 971ddc1..6cde4b7 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -139,9 +139,9 @@ - `async def on_documents_superseded(session, *, documents: Sequence[Document], now) -> list[uuid.UUID]` — версии «в работе», где эти документы привязаны (`FOR UPDATE` по `id`), удалить привязки; возвращает `unbound_version_ids`. - `DocumentService.confirm`: после установки `CONFIRMED` и перед аудитом — `on_document_confirmed(...)`; `supersede_previous`: после `UPDATE stored_file` — `on_documents_superseded(replaced_documents всех файлов)`, `unbound_version_ids` в `after_value` `FILE_REPLACE`. -- [ ] **Step 1: Тесты.** Unit `fits`: таблица из 8 случаев (член/не член, год, нарастающий Q2/Q3 при версии Q3, квартальный Q1/Q4, `period_quarter` NULL, `is_cumulative` NULL). Integration: сценарии `documents-api` «Привязка к версии и переход статуса», «Не подходит по периоду», «Версия в CANCELLED», «Фикстуры, подтверждённые до консолидации» (через `make_confirmed_osv` × 3 + создание через API), «Две подтверждённые версии одного документа»; `primary-files` «Заменённый документ был в версии» (через `supersede_previous` напрямую, как в `test_result_mapping`); Review Focus 1 — две версии Q2 и Q3 получают один квартальный документ Q1 и обе переходят в `DATA_CORRECTION`; порядок блокировок: подтверждение ∥ `PUT …/members` на двух сессиях не взаимоблокируется (таймаут 5 с). -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_consolidation_documents.py tests/test_api_documents.py tests/test_result_mapping.py tests/test_api_consolidations.py tests/test_api_consolidation_members.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене`. +- [x] **Step 1: Тесты.** Unit `fits`: таблица из 8 случаев (член/не член, год, нарастающий Q2/Q3 при версии Q3, квартальный Q1/Q4, `period_quarter` NULL, `is_cumulative` NULL). Integration: сценарии `documents-api` «Привязка к версии и переход статуса», «Не подходит по периоду», «Версия в CANCELLED», «Фикстуры, подтверждённые до консолидации» (через `make_confirmed_osv` × 3 + создание через API), «Две подтверждённые версии одного документа»; `primary-files` «Заменённый документ был в версии» (через `supersede_previous` напрямую, как в `test_result_mapping`); Review Focus 1 — две версии Q2 и Q3 получают один квартальный документ Q1 и обе переходят в `DATA_CORRECTION`; порядок блокировок: подтверждение ∥ `PUT …/members` на двух сессиях не взаимоблокируется (таймаут 5 с). +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_consolidation_documents.py tests/test_api_documents.py tests/test_result_mapping.py tests/test_api_consolidations.py tests/test_api_consolidation_members.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index c8f6429..b9e6fd5 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -214,3 +214,10 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая | | 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) | | 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) | +| 30 | 2026-10-06 | R-2, гонка «создание версии ∥ подтверждение/автозамена» закрыта `pg_advisory_xact_lock(hashtextextended('consolidation-group:<ГК>', 0))` (`repositories.consolidations.lock_group_bindings`): `ConsolidationService.create` берёт его сразу после проверки видимости ГК (до чтения ГСК, проверки дубля периода и подхвата), `lock_versions_for_entity` — первым шагом, если у ЮЛ есть ГК (ЮЛ без ГК не может быть членом версии). Полный порядок: файл → документ → advisory ГК → версии; автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; `PUT …/members` advisory не берёт. Тесты на двух сессиях в обе стороны (`test_create_waits_for_confirm_…`, `test_confirm_waits_for_create_…`), мутанты без advisory в любой из сторон их роняют | новая версия невидима до commit, `FOR UPDATE` версий её не ловит; advisory по ГК — минимальная сериализация, не трогающая правку периметра | создание консолидаций одной ГК (любых периодов) сериализуется с подтверждениями и автозаменами документов ЮЛ этой ГК — ожидание на время короткой транзакции; параллельные создания одного периода теперь получают 409 `CONSOLIDATION_EXISTS` проверкой, а не UNIQUE | +| 31 | 2026-10-06 | R-3: на `FOR NO KEY UPDATE` переведены только `lock_document` и `lock_file_documents`; `lock_file` (и `_lock_file` маппинга) остаётся `FOR UPDATE` | на `stored_file` нет FK из таблиц консолидации, в циклы привязки файл не входит; удаление файла (`DELETE stored_file`) под `FOR NO KEY UPDATE` повышало бы блокировку уже после захвата и могло бы встречно ждать `KEY SHARE` вставки задания | нулевая; если файл войдёт в цикл — та же правка `key_share=True` | +| 32 | 2026-10-06 | R-3 в текущих потоках не воспроизводится через API: подхват привязывает только документы, `CONFIRMED` в зафиксированном снимке, а подтверждение держит документ, который ещё не `CONFIRMED` (иначе сразу 409). Тест `test_document_lock_does_not_block_binding_insert` проверяет сам цикл блокировок на примитивах (`lock_document` + ожидание версии ∥ `lock_version` + вставка привязки): с `FOR UPDATE` — `DeadlockDetectedError`, с `FOR NO KEY UPDATE` — проходит. API-тесты «подтверждение ∥ `PUT …/members`» в обе стороны проверяют R-2 (ожидание и непотерянную привязку) | диспатч требовал мутант-проверку R-3; на уровне API цикла нет, но страховка дешёвая и защищает будущие пути (Task 7/11) | нулевая | +| 33 | 2026-10-06 | Автозамена снимает привязки ВСЕХ документов заменённого файла, а не только совпавших с новым по ключу автозамены | spec primary-files: «привязки документов заменённых файлов»; файл `SUPERSEDED` целиком, а подхват берёт документы лишь незаменённых файлов — оставшаяся привязка была бы «висячей» | документ заменённого файла, не повторённый в новом файле, выпадает из версии (пробел в чек-листе) — так и задумано спекой | +| 34 | 2026-10-06 | `FILE_REPLACE.after_value.unbound_version_ids` — версии, где сняты привязки документов ИМЕННО этого заменённого файла (по возрастанию `id`; пустой список, если не было); `on_documents_superseded` вызывается по одному разу на заменённый файл | событие пишется на каждый заменённый файл; общий список версий по всей автозамене приписал бы файлу чужие отвязки. Повторные вызовы берут ту же advisory ГК и те же версии (уже удержаны) — порядок блокировок не меняется | нулевая | +| 35 | 2026-10-06 | Выбор «самого позднего `confirmed_at`» при равенстве — по наибольшему `id`; `confirmed_at` NULL у `CONFIRMED` (только тестовые данные) считается самым ранним. `bind_confirmed` заменяет привязанный документ того же ключа победителем и не трогает уже привязанного победителя (повторный подхват — 0 новых привязок) | детерминизм; спека порядок при равенстве не задаёт | при одинаковом `confirmed_at` привязан «случайный по id» из двух — равноценных — документов | +| 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index e99661f..d705204 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -30,7 +30,7 @@ ## 3. Привязка документов и чек-лист -- [ ] 3.1 **Привязка.** `services/consolidation_documents.py:fits(document, version)`, `bind_confirmed(version, entity_ids)`, +- [x] 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` -- 2.47.2 From 0e1fbd80364330ec70ed0df66e99b5432a21f18d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 05:11:03 +0400 Subject: [PATCH 11/27] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20Task=206=20=E2=80=94=20=D0=B3=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=81=20=D0=BF=D0=BE=D0=B4=D1=85=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BF=D0=BE?= =?UTF-8?q?=20confirmed=5Fat,=20=D0=BE=D0=B4=D0=BD=D0=B0=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D0=93=D0=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - T6.0: тесты на двух сессиях «автозамена ∥ подхват при правке периметра» и «автозамена ∥ создание версии»: автозамена ждёт, снимает незафиксированную ранее привязку, unbound_version_ids — эта версия. - T6.2: тесты выбора самого позднего confirmed_at строятся так, чтобы «наибольший id» проигрывал; тест равенства confirmed_at (больший id, решение №35). - T6.3: реальный подхват через PUT …/members — версия в INITIATED, статус не меняется. - T6.4: lock_versions_for_entity не читает ГК ЮЛ второй раз (подзапрос заменён значением). - T6.5: design №30 — цена: подтверждения документов ЮЛ одной ГК сериализуются на advisory даже без версий в работе. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/consolidations.py | 9 +- api/tests/test_api_consolidation_members.py | 6 +- api/tests/test_consolidation_documents.py | 145 +++++++++++++++++- .../changes/slice-4-consolidation/design.md | 2 +- 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 2ccfa13..65e44f7 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -10,7 +10,7 @@ import uuid from collections import defaultdict from collections.abc import Collection, Sequence -from sqlalchemy import Select, exists, func, select +from sqlalchemy import Select, exists, false, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState @@ -132,14 +132,11 @@ async def lock_versions_for_entity( ).scalar_one_or_none() if group_id is not None: await lock_group_bindings(session, group_id) - entity_group = select(LegalEntity.company_group_id).where(LegalEntity.id == entity_id).scalar_subquery() + in_group = Consolidation.company_group_id == group_id if group_id is not None else false() superset = ( select(ConsolidationVersion.id) .join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id) - .where( - ConsolidationVersion.status.in_(statuses), - is_member | (Consolidation.company_group_id == entity_group), - ) + .where(ConsolidationVersion.status.in_(statuses), is_member | in_group) .order_by(ConsolidationVersion.id) .with_for_update(of=ConsolidationVersion) ) diff --git a/api/tests/test_api_consolidation_members.py b/api/tests/test_api_consolidation_members.py index 8c887a2..24750fd 100644 --- a/api/tests/test_api_consolidation_members.py +++ b/api/tests/test_api_consolidation_members.py @@ -158,8 +158,8 @@ async def test_add_member_really_binds_confirmed_documents( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: """Добавленное ЮЛ с подтверждённой ОСВ за квартал версии: `consolidation_document` создан без шпионов; статус - версии подхват не меняет; подтверждённая ОСВ другого года не подхватывается.""" - version, inside, outside, _ = await _version(seeded) + версии подхват не меняет (`INITIATED` остаётся); подтверждённая ОСВ другого года не подхватывается.""" + version, inside, outside, _ = await _version(seeded, status=ConsolidationStatus.INITIATED) fitting = await make_confirmed_osv( seeded, entity=outside[0], @@ -184,7 +184,7 @@ async def test_add_member_really_binds_confirmed_documents( assert response.status_code == HTTP_OK, response.text assert await _bound_documents(seeded, version.id) == {fitting.id} - assert response.json()["status"] == ConsolidationStatus.DATA_CORRECTION.value + assert response.json()["status"] == ConsolidationStatus.INITIATED.value @pytest.mark.integration diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 66964ff..e764b00 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -37,12 +37,13 @@ from src.models.enums import ( PeriodQuarter, RecognitionStatus, ) -from src.models.files import Document +from src.models.files import Document, StoredFile from src.repositories import consolidations as repo from src.repositories import files as files_repo from src.services import consolidation_documents from src.services.audit import AuditService from src.services.consolidation_documents import fits +from src.services.result_mapping import supersede_previous from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -403,15 +404,31 @@ async def test_create_picks_up_documents_confirmed_before(seeded: AsyncSession, assert len(await _history(seeded, version_id)) == 1 +async def _latest_with_smaller_id( + session: AsyncSession, entity: LegalEntity, *, account: str = ACCOUNT, equal: bool = False +) -> tuple[Document, Document]: + """Два `CONFIRMED` документа одного ключа (квартальная ОСВ за Q3): `(победитель, проигравший)`. + + Без `equal` более поздний `confirmed_at` получает документ с МЕНЬШИМ `id` — выбор «по наибольшему id» вместо + «по самому позднему `confirmed_at`» проиграет. С `equal` время одинаковое — побеждает больший `id` (решение №35). + """ + first = await _confirmed(session, entity, quarter=Q3, account=account) + second = await _confirmed(session, entity, quarter=Q3, account=account) + smaller, larger = sorted((first, second), key=lambda document: document.id) + now = utc_now() + smaller.confirmed_at = now + larger.confirmed_at = now if equal else now - datetime.timedelta(hours=1) + await session.flush() + return (larger, smaller) if equal else (smaller, larger) + + @pytest.mark.integration async def test_create_picks_latest_confirmed_of_same_key(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """«Две подтверждённые версии одного документа»: два незаменённых файла с `CONFIRMED` ОСВ 62 за Q3 — привязан документ с более поздним `confirmed_at`.""" group, entities = await _group(seeded) gsk = await _gsk(seeded, group, entities) - now = utc_now() - await _confirmed(seeded, entities[0], quarter=Q3, account="62", confirmed_at=now - datetime.timedelta(hours=1)) - later = await _confirmed(seeded, entities[0], quarter=Q3, account="62", confirmed_at=now) + later, _ = await _latest_with_smaller_id(seeded, entities[0], account="62") client = await auth_client(KA_RF) response = await client.post("/consolidations", json=_create_body(group, gsk)) @@ -458,9 +475,7 @@ async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSe version = await _version(seeded, group, entities) consolidation = await seeded.get(Consolidation, version.consolidation_id) assert consolidation is not None - now = utc_now() - older = await _confirmed(seeded, entities[0], quarter=Q3, confirmed_at=now - datetime.timedelta(hours=1)) - newer = await _confirmed(seeded, entities[0], quarter=Q3, confirmed_at=now) + newer, older = await _latest_with_smaller_id(seeded, entities[0]) await _bind(seeded, version, older) count = await consolidation_documents.bind_confirmed( @@ -471,6 +486,23 @@ async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSe assert await _bound(seeded, version.id) == {newer.id} +@pytest.mark.integration +async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None: + """Решение №35: при равном `confirmed_at` привязывается документ с большим `id`.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + winner, _ = await _latest_with_smaller_id(seeded, entities[0], equal=True) + + count = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + + assert count == 1 + assert await _bound(seeded, version.id) == {winner.id} + + # --- гонки (настоящие параллельные транзакции) ------------------------------------------------------------------------ @@ -679,3 +711,102 @@ async def test_confirm_waits_for_create_and_binds_new_version( assert await _committed_bound(maker, version_id) == {data["document_id"]} async with maker() as session: assert await _status(session, version_id) is ConsolidationStatus.DATA_CORRECTION + + +async def _committed_replacement(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> uuid.UUID: + """У ЮЛ документа `data` — прежний файл с `CONFIRMED` квартальной ОСВ 60 за Q2 (тот же ключ автозамены, что у + документа `data`, файл которого станет новым). Возвращает id прежнего документа.""" + async with maker() as session, session.begin(): + entity = await session.get(LegalEntity, data["owner_id"]) + assert entity is not None + return (await _confirmed(session, entity, quarter=Q2)).id + + +async def _supersede(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> list[uuid.UUID]: + """Автозамена в отдельной транзакции: новый файл — файл документа `data` (как в конце `map_file`).""" + async with maker() as session, session.begin(): + new_file = await session.get(StoredFile, data["file_id"]) + new_document = await session.get(Document, data["document_id"]) + assert new_file is not None + assert new_document is not None + replaced = await supersede_previous( + session, new_file=new_file, new_documents=[new_document], uploader_id=None, now=utc_now() + ) + return [file.id for file in replaced] + + +async def _replace_event(maker: async_sessionmaker[AsyncSession]) -> AuditEvent: + async with maker() as session: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_REPLACE) + return (await session.execute(stmt)).scalar_one() + + +@pytest.mark.integration +async def test_supersede_waits_for_pick_up_and_unbinds_replaced_document( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-2, автозамена ∥ подхват: правка периметра держит версию, добавила ЮЛ и подхватила его `CONFIRMED` документ + (ещё не зафиксировано). Автозамена файла этого документа блокирует надмножество версий ГК, ждёт правку и после её + commit снимает уже видимую привязку; `unbound_version_ids` — эта версия. Блокируй автозамена только версии, где + документ уже привязан, — она не увидела бы незафиксированную привязку и оставила бы её висеть.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + old_document_id = await _committed_replacement(maker, data) + + async with maker() as editor, editor.begin(): + version = await repo.lock_version(editor, data["version_id"]) + assert version is not None + consolidation = await editor.get(Consolidation, version.consolidation_id) + assert consolidation is not None + editor.add(ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=data["owner_id"])) + await editor.flush() + assert ( + await consolidation_documents.bind_confirmed( + editor, version=version, consolidation=consolidation, entity_ids=[data["owner_id"]] + ) + == 1 + ) + replacement = asyncio.create_task(_supersede(maker, data)) + await _wait_for_lock_waiter(maker) + assert not replacement.done() + replaced = await asyncio.wait_for(replacement, LOCK_WAIT_SECONDS) + + assert len(replaced) == 1 + assert old_document_id not in await _committed_bound(maker, data["version_id"]) + event = await _replace_event(maker) + assert event.after_value is not None + assert event.after_value["unbound_version_ids"] == [str(data["version_id"])] + + +@pytest.mark.integration +async def test_supersede_waits_for_create_and_unbinds_replaced_document( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-2, автозамена ∥ создание версии: создание держит advisory ГК и подхватило `CONFIRMED` документ в новую + (невидимую до commit) версию; автозамена ждёт advisory ГК и после commit снимает привязку новой версии.""" + maker = committed_sessionmaker + data = await _committed_setup(maker, spare=0, with_version=False) + old_document_id = await _committed_replacement(maker, data) + + async with maker() as creator, creator.begin(): + await repo.lock_group_bindings(creator, data["group_id"]) # как `ConsolidationService.create` + group = await creator.get(CompanyGroup, data["group_id"]) + assert group is not None + entities = [await creator.get(LegalEntity, entity_id) for entity_id in data["entity_ids"]] + version = await _version(creator, group, [entity for entity in entities if entity is not None]) + consolidation = await creator.get(Consolidation, version.consolidation_id) + assert consolidation is not None + bound = await consolidation_documents.bind_confirmed( + creator, version=version, consolidation=consolidation, entity_ids=data["entity_ids"] + ) + assert bound == 1 + version_id = version.id + replacement = asyncio.create_task(_supersede(maker, data)) + await _wait_for_lock_waiter(maker) + assert not replacement.done() + await asyncio.wait_for(replacement, LOCK_WAIT_SECONDS) + + assert old_document_id not in await _committed_bound(maker, version_id) + event = await _replace_event(maker) + assert event.after_value is not None + assert event.after_value["unbound_version_ids"] == [str(version_id)] diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index b9e6fd5..a1c0dd1 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -214,7 +214,7 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая | | 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) | | 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) | -| 30 | 2026-10-06 | R-2, гонка «создание версии ∥ подтверждение/автозамена» закрыта `pg_advisory_xact_lock(hashtextextended('consolidation-group:<ГК>', 0))` (`repositories.consolidations.lock_group_bindings`): `ConsolidationService.create` берёт его сразу после проверки видимости ГК (до чтения ГСК, проверки дубля периода и подхвата), `lock_versions_for_entity` — первым шагом, если у ЮЛ есть ГК (ЮЛ без ГК не может быть членом версии). Полный порядок: файл → документ → advisory ГК → версии; автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; `PUT …/members` advisory не берёт. Тесты на двух сессиях в обе стороны (`test_create_waits_for_confirm_…`, `test_confirm_waits_for_create_…`), мутанты без advisory в любой из сторон их роняют | новая версия невидима до commit, `FOR UPDATE` версий её не ловит; advisory по ГК — минимальная сериализация, не трогающая правку периметра | создание консолидаций одной ГК (любых периодов) сериализуется с подтверждениями и автозаменами документов ЮЛ этой ГК — ожидание на время короткой транзакции; параллельные создания одного периода теперь получают 409 `CONSOLIDATION_EXISTS` проверкой, а не UNIQUE | +| 30 | 2026-10-06 | R-2, гонка «создание версии ∥ подтверждение/автозамена» закрыта `pg_advisory_xact_lock(hashtextextended('consolidation-group:<ГК>', 0))` (`repositories.consolidations.lock_group_bindings`): `ConsolidationService.create` берёт его сразу после проверки видимости ГК (до чтения ГСК, проверки дубля периода и подхвата), `lock_versions_for_entity` — первым шагом, если у ЮЛ есть ГК (ЮЛ без ГК не может быть членом версии). Полный порядок: файл → документ → advisory ГК → версии; автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; `PUT …/members` advisory не берёт. Тесты на двух сессиях в обе стороны (`test_create_waits_for_confirm_…`, `test_confirm_waits_for_create_…`), мутанты без advisory в любой из сторон их роняют | новая версия невидима до commit, `FOR UPDATE` версий её не ловит; advisory по ГК — минимальная сериализация, не трогающая правку периметра | создание консолидаций одной ГК (любых периодов) сериализуется с подтверждениями и автозаменами документов ЮЛ этой ГК — ожидание на время короткой транзакции; параллельные создания одного периода теперь получают 409 `CONSOLIDATION_EXISTS` проверкой, а не UNIQUE. Все подтверждения (и автозамены) документов ЮЛ одной ГК теперь сериализуются на advisory-блокировке этой ГК, даже если у ГК нет ни одной версии «в работе» (lock берётся до поиска версий) | | 31 | 2026-10-06 | R-3: на `FOR NO KEY UPDATE` переведены только `lock_document` и `lock_file_documents`; `lock_file` (и `_lock_file` маппинга) остаётся `FOR UPDATE` | на `stored_file` нет FK из таблиц консолидации, в циклы привязки файл не входит; удаление файла (`DELETE stored_file`) под `FOR NO KEY UPDATE` повышало бы блокировку уже после захвата и могло бы встречно ждать `KEY SHARE` вставки задания | нулевая; если файл войдёт в цикл — та же правка `key_share=True` | | 32 | 2026-10-06 | R-3 в текущих потоках не воспроизводится через API: подхват привязывает только документы, `CONFIRMED` в зафиксированном снимке, а подтверждение держит документ, который ещё не `CONFIRMED` (иначе сразу 409). Тест `test_document_lock_does_not_block_binding_insert` проверяет сам цикл блокировок на примитивах (`lock_document` + ожидание версии ∥ `lock_version` + вставка привязки): с `FOR UPDATE` — `DeadlockDetectedError`, с `FOR NO KEY UPDATE` — проходит. API-тесты «подтверждение ∥ `PUT …/members`» в обе стороны проверяют R-2 (ожидание и непотерянную привязку) | диспатч требовал мутант-проверку R-3; на уровне API цикла нет, но страховка дешёвая и защищает будущие пути (Task 7/11) | нулевая | | 33 | 2026-10-06 | Автозамена снимает привязки ВСЕХ документов заменённого файла, а не только совпавших с новым по ключу автозамены | spec primary-files: «привязки документов заменённых файлов»; файл `SUPERSEDED` целиком, а подхват берёт документы лишь незаменённых файлов — оставшаяся привязка была бы «висячей» | документ заменённого файла, не повторённый в новом файле, выпадает из версии (пробел в чек-листе) — так и задумано спекой | -- 2.47.2 From 190828861395ebdea233fcc9e2105517ffcfb884 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 05:30:42 +0400 Subject: [PATCH 12/27] =?UTF-8?q?feat(consolidation):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=B4=D1=8B=20DOCUMENTS=5FPROCESSING=20?= =?UTF-8?q?=D0=B8=20DATA=5FCORRECTION=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B5=20=D0=B8=20=D0=BC=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D0=B3=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Загрузка с consolidation_version_id: проверки до приёма (404/409/422), после приёма INITIATED -> DOCUMENTS_PROCESSING в отдельной транзакции без блокировки версии. Маппинг: on_documents_mapped переводит версии DOCUMENTS_PROCESSING в DATA_CORRECTION при подходящем документе NEEDS_CHECK (changed_by = uploaded_by), привязки не создаёт. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/files.py | 6 +- api/src/api/endpoints/files/upload.py | 29 ++- api/src/services/consolidation_documents.py | 67 +++++- api/src/services/primary_files.py | 65 +++++- api/src/services/result_mapping.py | 8 +- api/tests/test_api_files_upload.py | 207 +++++++++++++++++- api/tests/test_consolidation_documents.py | 164 ++++++++++++++ api/tests/test_result_mapping.py | 132 ++++++++++- .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 6 + .../changes/slice-4-consolidation/tasks.md | 2 +- 11 files changed, 676 insertions(+), 16 deletions(-) diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index 4cde09f..5ef5a49 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -20,7 +20,11 @@ def get_primary_files_service( app_settings: SettingsDep, ) -> PrimaryFilesService: """Сервис первичных файлов; S3 — `app.state.storage` (`None`, если S3 недоступно или не подключено).""" - return PrimaryFilesService(session, audit, scope, getattr(request.app.state, "storage", None), app_settings) + # отдельная короткая транзакция (переход версии после загрузки) — та же фабрика, что у аудита входа + transaction = getattr(getattr(request.app.state, "session_audit", None), "transaction", None) + return PrimaryFilesService( + session, audit, scope, getattr(request.app.state, "storage", None), app_settings, transaction + ) PrimaryFilesServiceDep = Annotated[PrimaryFilesService, Depends(get_primary_files_service)] diff --git a/api/src/api/endpoints/files/upload.py b/api/src/api/endpoints/files/upload.py index 7c0e44f..9cf9ab1 100644 --- a/api/src/api/endpoints/files/upload.py +++ b/api/src/api/endpoints/files/upload.py @@ -10,7 +10,7 @@ multipart: одно или несколько полей `files`; право `DO import uuid from typing import Annotated -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep @@ -30,8 +30,18 @@ router = APIRouter() status_code=status.HTTP_201_CREATED, dependencies=[Depends(require("DOCUMENT_EDIT"))], responses={ - status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ЮЛ нет или оно вне видимости"}, - status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": UploadResult, "description": "ни один файл не принят"}, + status.HTTP_404_NOT_FOUND: { + "model": ErrorDetail, + "description": "ЮЛ нет или оно вне видимости; версия консолидации не найдена или чужая (VERSION_NOT_FOUND)", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "версия консолидации не в работе (VERSION_NOT_EDITABLE)", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": UploadResult, + "description": "ни один файл не принят; ЮЛ вне периметра версии (ENTITY_NOT_IN_PERIMETER)", + }, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "S3 не подключено"}, }, summary="Загрузка файлов организации", @@ -41,9 +51,18 @@ async def upload_files( files: Annotated[list[UploadFile], File(description="Файлы ОСВ/ББ/ОФР: xls, xlsx, xlsb, pdf, png, jpg")], service: PrimaryFilesServiceDep, response: Response, + consolidation_version_id: Annotated[ + uuid.UUID | None, + Query(description="Версия консолидации, в контексте которой загружаются файлы (необязательно)"), + ] = None, ) -> UploadResult: - """Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля).""" + """Принятые файлы (`file_id`, `job_id`, имя) и отклонённые (имя, код, пояснение, `file_id` дубля). + + С `consolidation_version_id`: до приёма — версия видима, «в работе», ЮЛ в периметре; после приёма хотя бы одного + файла версия `INITIATED` → `DOCUMENTS_PROCESSING` (отдельная транзакция, блокировка версии не нужна).""" entity = await service.get_entity(legal_entity_id) + if consolidation_version_id is not None: + await service.check_version_for_upload(consolidation_version_id, entity) service.require_storage() accepted: list[AcceptedOut] = [] rejected: list[RejectedOut] = [] @@ -69,4 +88,6 @@ async def upload_files( accepted.append(AcceptedOut(file_id=result.file_id, job_id=result.job_id, name=result.name)) if not accepted: response.status_code = status.HTTP_422_UNPROCESSABLE_CONTENT + elif consolidation_version_id is not None: + await service.after_upload_to_version(consolidation_version_id) return UploadResult(accepted=accepted, rejected=rejected) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 8c10ba8..4fdbd5f 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -22,6 +22,7 @@ import uuid from collections.abc import Collection, Iterable, Sequence from typing import Any +from loguru import logger from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession @@ -33,7 +34,14 @@ from src.models.files import Document, StoredFile from src.repositories import consolidations as repo from src.services.audit import AuditService -__all__ = ["bind_confirmed", "fits", "on_document_confirmed", "on_documents_superseded", "unbind_entity"] +__all__ = [ + "bind_confirmed", + "fits", + "on_document_confirmed", + "on_documents_mapped", + "on_documents_superseded", + "unbind_entity", +] #: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6). MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING}) @@ -248,3 +256,60 @@ async def on_documents_superseded( if await _unbind(session, version.id, document_ids): unbound.add(version.id) return sorted(unbound) + + +async def on_documents_mapped( + session: AsyncSession, + *, + file: StoredFile, + documents: Sequence[Document], + now: datetime.datetime, +) -> list[uuid.UUID]: + """Хук маппинга (osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку», D-6): каждая версия в + `DOCUMENTS_PROCESSING`, в периметр которой входит ЮЛ файла, переходит в `DATA_CORRECTION` + (`changed_by = file.uploaded_by`), если среди `documents` есть документ `NEEDS_CHECK`, подходящий версии (`fits`). + Привязки `consolidation_document` НЕ создаются (их создаёт только подтверждение и подхват). Возвращает id версий, + переведённых в `DATA_CORRECTION` (по возрастанию). Файл без `uploaded_by` — переход пропускается с записью в лог + (`changed_by NOT NULL`, design №2). + + Блокировки (R-2): `lock_versions_for_entity` берёт advisory ГК и блокирует НАДМНОЖЕСТВО версий «в работе» ГК + (`IN_WORK_STATUSES`, а не только `DOCUMENTS_PROCESSING`): статус фильтруется ПОСЛЕ блокировки по свежему состоянию + (`populate_existing`), поэтому версия, которую параллельно перевели в `DOCUMENTS_PROCESSING` или в которую + параллельно добавили ЮЛ, не теряется. Вызывать в транзакции маппинга ПОСЛЕ `map_file` (файл и документы уже + заблокированы, автозамена прежних версий уже взяла advisory ЮЛ → старые файлы → advisory ГК → версии): порядок + «задание → файл → документы → версии» не нарушается, версии блокируются последними. + """ + if file.uploaded_by is None: + logger.info( + "Переход версий по маппингу пропущен: у файла нет uploaded_by (changed_by обязателен). file_id={file_id}", + file_id=file.id, + ) + return [] + needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK] + if not needs_check: + return [] + # цикл импортов: consolidation_status → consolidations → consolidation_documents (design №36) + from src.services.consolidation_status import transition # noqa: PLC0415 + + audit = AuditService(session) + moved: list[uuid.UUID] = [] + for version in await repo.lock_versions_for_entity(session, file.legal_entity_id, IN_WORK_STATUSES): + if version.status is not ConsolidationStatus.DOCUMENTS_PROCESSING: + continue + consolidation = await session.get(Consolidation, version.consolidation_id) + if consolidation is None: # FK гарантирует наличие + continue + members = await _member_ids(session, version.id) + if not any(fits(document, version, consolidation, members) for document in needs_check): + continue + await transition( + session, + audit, + version=version, + to=ConsolidationStatus.DATA_CORRECTION, + changed_by=file.uploaded_by, + comment=None, + now=now, + ) + moved.append(version.id) + return moved diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 4ed1bcd..85e42b6 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -18,6 +18,8 @@ audit-trail «События файлов и документов»; design D-4) import hashlib import uuid +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from loguru import logger @@ -27,27 +29,32 @@ from starlette import status as http_status from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now -from src.common.exceptions import ConflictError, DomainError, NotFoundError +from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError from src.common.filenames import safe_filename from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings from src.core.storage import ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file -from src.domain.policy import VisibilityScope +from src.domain.policy import IN_WORK_STATUSES, VisibilityScope +from src.models.consolidation import ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, + ConsolidationStatus, DocumentStatus, FileStatus, RecognitionStatus, S3BucketRole, ) from src.models.files import RecognitionJob, S3Object, StoredFile +from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.schemas.common import PageParams from src.schemas.files import FileOut +from src.services.audit import AuditService +from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.result_mapping import RESULTS_PREFIX @@ -62,6 +69,9 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" +#: фабрика отдельной короткой транзакции (в проде — `src.db.db.session_transaction`): commit на выходе +TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] + class StorageUnavailableError(DomainError): """503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект.""" @@ -108,19 +118,21 @@ def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str: class PrimaryFilesService: """Загрузка первичных файлов ЮЛ от имени пользователя запроса (право `DOCUMENT_EDIT` — на маршруте).""" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 - зависимости сервиса; `transaction` — отдельная транзакция (D-6) self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, storage: ObjectStorage | None, settings: Settings, + transaction: TransactionFactory | None = None, ) -> None: self._session = session self._audit = audit self._scope = scope self._storage = storage self._settings = settings + self._transaction = transaction @property def max_upload_bytes(self) -> int: @@ -134,6 +146,53 @@ class PrimaryFilesService: raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) return entity + async def check_version_for_upload(self, version_id: uuid.UUID, entity: LegalEntity) -> ConsolidationVersion: + """Загрузка в контексте версии консолидации (spec primary-files «Загрузка файлов организации», D-6): ДО приёма + файлов версия видима (иначе 404 `VERSION_NOT_FOUND`) → статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) → + ЮЛ — член периметра (иначе 422 `ENTITY_NOT_IN_PERIMETER`). Блокировка версии (`consolidation_lock`) и + `FOR UPDATE` не нужны: статус перепроверяется под блокировкой в `after_upload_to_version`.""" + found = await consolidations_repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version = found[0] + if version.status not in IN_WORK_STATUSES: + raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется") + members = await consolidations_repo.members(self._session, version.id) + if all(member.legal_entity_id != entity.id for member, _ in members): + raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версии консолидации") + return version + + async def after_upload_to_version(self, version_id: uuid.UUID) -> None: + """После приёма хотя бы одного файла: версия `INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший). + + В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус + перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия + блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов + (D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет.""" + # импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга + from src.services.consolidation_status import transition # noqa: PLC0415 + + factory = self._transaction + if factory is None: + from src.db.db import session_transaction # noqa: PLC0415 + + factory = session_transaction + async with factory() as session: + version = await consolidations_repo.lock_version(session, version_id) + if version is None or version.status is not ConsolidationStatus.INITIATED: + return + await transition( + session, + AuditService(session), + version=version, + to=ConsolidationStatus.DOCUMENTS_PROCESSING, + changed_by=self._audit.user_id, + comment=None, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=utc_now(), + ) + def require_storage(self) -> ObjectStorage: """S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`.""" if self._storage is None: diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 6e4df05..de61027 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -681,7 +681,13 @@ async def _map_candidate( rows = await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) chart = {code: account_id for code, account_id in rows} index = await CounterpartyIndex.load(session) - warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=clock()) + now = clock() + warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now) + # Переход версий (D-6) — ПОСЛЕ `map_file`, а значит после автозамены (`supersede_previous` берёт advisory ЮЛ → + # старые файлы → advisory ГК → версии): версии блокируются последними, порядок «задание → файл → документы → + # версии» (D-9) не нарушается. До автозамены хук держал бы версии и затем ждал блокировки старых файлов. + documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars()) + await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now) logger.info( "Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}", job_id=candidate.job_id, diff --git a/api/tests/test_api_files_upload.py b/api/tests/test_api_files_upload.py index 26e7430..5a68fbe 100644 --- a/api/tests/test_api_files_upload.py +++ b/api/tests/test_api_files_upload.py @@ -24,12 +24,15 @@ from src.api.dependencies.directories import get_app_settings from src.core.config import get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, AuditSection, + ConsolidationStatus, FileStatus, FileType, + PeriodQuarter, RecognitionPipeline, RecognitionStatus, S3BucketRole, @@ -37,7 +40,7 @@ from src.models.enums import ( from src.models.files import RecognitionJob, S3Object, StoredFile from src.services.recognition_exchange import publish_pending from tests.conftest import AuthClientFactory, FakeStorage -from tests.factories import make_company_group, make_legal_entity +from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats HTTP_CREATED = 201 @@ -531,3 +534,205 @@ async def test_concurrent_duplicates_accept_only_one( files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one() assert files == 1 assert len(committed_storage.objects) == 1 + + +# --- загрузка в контексте версии консолидации (spec primary-files «Загрузка в контексте версии») --------------- + +HTTP_CONFLICT = 409 +KRASNODAR = "Краснодарский региональный филиал" + + +async def _group_with_members( + session: AsyncSession, *, office_name: str = ROSTOV, members: int = 2 +) -> tuple[Any, list[LegalEntity]]: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one() + group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office) + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)] + return group, entities + + +async def _version( + session: AsyncSession, + *, + status: ConsolidationStatus = ConsolidationStatus.INITIATED, + office_name: str = ROSTOV, +) -> tuple[ConsolidationVersion, list[LegalEntity], Any]: + """Версия 2024 Q3 ГК филиала `office_name` с двумя членами; третье ЮЛ той же ГК — вне периметра.""" + group, entities = await _group_with_members(session, office_name=office_name, members=3) + initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation( + session, + group=group, + year=2024, + quarter=PeriodQuarter.Q3, + members=entities[:2], + initiator=initiator, + status=status, + ) + return version, entities, initiator + + +async def _version_state(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationStatus: + stmt = select(ConsolidationVersion.status).where(ConsolidationVersion.id == version_id) + return (await session.execute(stmt.execution_options(populate_existing=True))).scalar_one() + + +async def _post_in_version( + client: httpx.AsyncClient, entity_id: uuid.UUID, version_id: uuid.UUID, *uploads: Upload +) -> httpx.Response: + files = [("files", (name, content, "application/octet-stream")) for name, content in uploads] + return await client.post(_files_url(entity_id), params={"consolidation_version_id": str(version_id)}, files=files) + + +@pytest.mark.integration +async def test_upload_in_version_moves_initiated_to_documents_processing( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + version, (member, *_), _ = await _version(seeded) + user_id = await _user_id(seeded, KA_RF) + client = await auth_client(KA_RF) + + response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("in-version"))) + + assert response.status_code == HTTP_CREATED, response.text + assert len(response.json()["accepted"]) == 1 + assert len(storage.objects) == 1 + assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + history = ( + ( + await seeded.execute( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version.id) + .order_by(ConsolidationStatusHistory.changed_at.desc()) + ) + ) + .scalars() + .first() + ) + assert history is not None + assert (history.from_status, history.to_status) == ( + ConsolidationStatus.INITIATED, + ConsolidationStatus.DOCUMENTS_PROCESSING, + ) + assert history.changed_by == user_id + [event] = ( + (await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE))) + .scalars() + .all() + ) + assert event.user_id == user_id + assert event.consolidation_version_id == version.id + + +@pytest.mark.integration +@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION]) +async def test_upload_in_version_keeps_other_in_work_status( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, status: ConsolidationStatus +) -> None: + version, (member, *_), _ = await _version(seeded, status=status) + client = await auth_client(KA_RF) + + response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls(f"keep-{status.value}"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is status + assert len(storage.objects) == 1 + + +@pytest.mark.integration +async def test_upload_with_all_files_rejected_does_not_move_version( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + version, (member, *_), _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await _post_in_version(client, member.id, version.id, ("notes.docx", ZIP + b"x")) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["accepted"] == [] + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert storage.objects == {} + + +@pytest.mark.integration +async def test_upload_for_entity_outside_perimeter_is_422_and_writes_nothing( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + version, entities, _ = await _version(seeded) + outsider = entities[2] + client = await auth_client(KA_RF) + before = await _counts(seeded) + + response = await _post_in_version(client, outsider.id, version.id, ("osv.xls", _xls("outsider"))) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER" + assert await _counts(seeded) == before + assert storage.objects == {} + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + + +@pytest.mark.integration +async def test_upload_in_cancelled_version_is_409( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + version, (member, *_), _ = await _version(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + before = await _counts(seeded) + + response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("cancelled"))) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert await _counts(seeded) == before + assert storage.objects == {} + + +@pytest.mark.integration +async def test_upload_in_foreign_version_is_404_like_missing( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + foreign, _, _ = await _version(seeded, office_name=KRASNODAR) + own_entity = await _rostov_entity(seeded, "ООО Своё") + client = await auth_client(KA_RF) + before = await _counts(seeded) + + foreign_response = await _post_in_version(client, own_entity.id, foreign.id, ("osv.xls", _xls("foreign-version"))) + missing_response = await _post_in_version(client, own_entity.id, uuid.uuid4(), ("osv.xls", _xls("missing-version"))) + + assert foreign_response.status_code == missing_response.status_code == HTTP_NOT_FOUND, foreign_response.text + assert foreign_response.json() == missing_response.json() + assert foreign_response.json()["detail"]["code"] == "NOT_FOUND" + assert await _counts(seeded) == before + assert storage.objects == {} + + +@pytest.mark.integration +async def test_upload_does_not_require_version_lock( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """Блокировка версии для загрузки не нужна (spec): версию держит другой пользователь, загрузка проходит.""" + version, (member, *_), _ = await _version(seeded) + holder = (await seeded.execute(select(AppUser).where(AppUser.email == CLIENT_MANAGER))).scalar_one() + await make_lock(seeded, version=version, user=holder) + client = await auth_client(KA_RF) + + response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("under-lock"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + assert len(storage.objects) == 1 + + +@pytest.mark.integration +async def test_upload_without_version_does_not_touch_versions( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + version, (member, *_), _ = await _version(seeded) + client = await auth_client(KA_RF) + + response = await _post(client, member.id, ("osv.xls", _xls("no-version"))) + + assert response.status_code == HTTP_CREATED, response.text + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert len(storage.objects) == 1 diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index e764b00..700dd62 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -14,6 +14,7 @@ from collections.abc import Sequence from typing import Any import pytest +from loguru import logger from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -810,3 +811,166 @@ async def test_supersede_waits_for_create_and_unbinds_replaced_document( event = await _replace_event(maker) assert event.after_value is not None assert event.after_value["unbound_version_ids"] == [str(version_id)] + + +# --- on_documents_mapped: NEEDS_CHECK при маппинге (osv-result-mapping, D-6) ------------------------------- + + +async def _mapped_file_documents( + session: AsyncSession, entity: LegalEntity, *, uploaded_by: uuid.UUID | None, status: DocumentStatus, quarter=Q2 +) -> tuple[StoredFile, list[Document]]: + """Файл `PROCESSED` ЮЛ с одним квартальным документом ОСВ 60 за 2024 `quarter` в статусе `status`.""" + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, uploaded_by=uploaded_by + ) + document = await make_document( + session, file=file, job=job, account=ACCOUNT, year=YEAR, quarter=quarter, status=status, is_cumulative=False + ) + return file, [document] + + +@pytest.mark.integration +async def test_on_documents_mapped_moves_documents_processing_to_data_correction(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING) + user = await _user(seeded) + file, documents = await _mapped_file_documents( + seeded, entities[0], uploaded_by=user.id, status=DocumentStatus.NEEDS_CHECK + ) + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [version.id] + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + last = (await _history(seeded, version.id))[-1] + assert (last.from_status, last.to_status, last.changed_by) == ( + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, + user.id, + ) + [event] = ( + await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE)) + ).scalars() + assert event.user_id == user.id + assert await _bound(seeded, version.id) == set() # привязку маппинг не создаёт + + +@pytest.mark.integration +@pytest.mark.parametrize("status", [ConsolidationStatus.INITIATED, ConsolidationStatus.DATA_CORRECTION]) +async def test_on_documents_mapped_ignores_other_statuses(seeded: AsyncSession, status: ConsolidationStatus) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=status) + user = await _user(seeded) + file, documents = await _mapped_file_documents( + seeded, entities[0], uploaded_by=user.id, status=DocumentStatus.NEEDS_CHECK + ) + history = len(await _history(seeded, version.id)) + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [] + assert await _status(seeded, version.id) is status + assert len(await _history(seeded, version.id)) == history + + +@pytest.mark.integration +async def test_on_documents_mapped_ignores_recognized_document(seeded: AsyncSession) -> None: + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING) + file, documents = await _mapped_file_documents( + seeded, entities[0], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.RECOGNIZED + ) + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [] + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + + +@pytest.mark.integration +async def test_on_documents_mapped_skips_document_that_does_not_fit_version(seeded: AsyncSession) -> None: + """Квартальный документ Q4 не подходит версии Q3 (D-5): `NEEDS_CHECK`, но перехода нет.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING) + file, documents = await _mapped_file_documents( + seeded, entities[0], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.NEEDS_CHECK, quarter=Q4 + ) + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [] + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + + +@pytest.mark.integration +async def test_on_documents_mapped_skips_entity_outside_perimeter(seeded: AsyncSession) -> None: + group, entities = await _group(seeded, members=3) + version = await _version(seeded, group, entities[:2], status=ConsolidationStatus.DOCUMENTS_PROCESSING) + file, documents = await _mapped_file_documents( + seeded, entities[2], uploaded_by=(await _user(seeded)).id, status=DocumentStatus.NEEDS_CHECK + ) + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [] + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + + +@pytest.mark.integration +async def test_on_documents_mapped_without_uploader_logs_and_skips(seeded: AsyncSession) -> None: + """`uploaded_by` NULL: `changed_by NOT NULL` — переход пропускается, в лог пишется причина, ошибки нет.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DOCUMENTS_PROCESSING) + file, documents = await _mapped_file_documents( + seeded, entities[0], uploaded_by=None, status=DocumentStatus.NEEDS_CHECK + ) + messages: list[str] = [] + sink = logger.add(lambda message: messages.append(str(message)), level="INFO") + try: + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + finally: + logger.remove(sink) + + assert moved == [] + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + assert any(str(file.id) in message and "uploaded_by" in message for message in messages) + + +@pytest.mark.integration +async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-2: правка периметра держит версию `DOCUMENTS_PROCESSING` и добавила ЮЛ файла (не зафиксировано); хук маппинга + блокирует надмножество версий ГК, ждёт правку и после commit видит нового члена — переход не теряется.""" + maker = committed_sessionmaker + data = await _committed_setup(maker) + async with maker() as session, session.begin(): + version = await session.get(ConsolidationVersion, data["version_id"]) + assert version is not None + version.status = ConsolidationStatus.DOCUMENTS_PROCESSING + file = await session.get(StoredFile, data["file_id"]) + assert file is not None + file.uploaded_by = data["user_id"] + + async def mapped() -> list[uuid.UUID]: + async with maker() as session, session.begin(): + file = await session.get(StoredFile, data["file_id"]) + document = await session.get(Document, data["document_id"]) + assert file is not None + assert document is not None + return await consolidation_documents.on_documents_mapped( + session, file=file, documents=[document], now=utc_now() + ) + + async with maker() as editor, editor.begin(): + assert await repo.lock_version(editor, data["version_id"]) is not None + editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"])) + await editor.flush() + hook = asyncio.create_task(mapped()) + await _wait_for_lock_waiter(maker) + assert not hook.done() + moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS) + + assert moved == [data["version_id"]] + async with maker() as session: + assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index 9798bef..dfa06b3 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -29,7 +29,7 @@ from src.core.config import Settings, get_settings from src.models.access import AppUser from src.models.accounting import ChartOfAccount from src.models.audit_ex import AuditEvent -from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.consolidation import ConsolidationDocument, ConsolidationStatusHistory, ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, @@ -78,6 +78,7 @@ GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОС GOLDEN_OSV_60 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_60 за 2024.json" BUCKET = "arm-ka" OWNER_NAME = "АО «Русский бисквит»" +FACTORY_HISTORY = 2 # `make_consolidation(status=DOCUMENTS_PROCESSING)`: NULL → INITIATED → DOCUMENTS_PROCESSING PERF_BUDGET_SECONDS = 2.0 # цель плана: маппинг ~1000 строк ОСВ_60 за 2024 быстрее 2 с pytestmark = pytest.mark.integration @@ -1212,6 +1213,135 @@ async def test_replaced_document_is_unbound_from_versions_in_work( assert event.after_value == {"file_id": str(new_file.id), "version_no": 2, "unbound_version_ids": [str(in_work_id)]} +# --- osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку» (D-6) ------------------------------ + + +async def _version_in( + maker: async_sessionmaker[AsyncSession], + *, + status: ConsolidationStatus = ConsolidationStatus.DOCUMENTS_PROCESSING, + quarter: PeriodQuarter = PeriodQuarter.Q3, +) -> tuple[uuid.UUID, uuid.UUID, uuid.UUID]: + """ГК с ЮЛ-владельцем (ИНН как в результате) и вторым ЮЛ; версия `quarter` 2025; `(entity, version, user)`.""" + async with maker() as session, session.begin(): + initiator = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one() + group = await make_company_group(session, name="ГК Маппинг версии") + owner = await make_legal_entity(session, name=OWNER_NAME, inn="7700000000", group=group) + other = await make_legal_entity(session, name="ООО Второе", group=group) + _, version = await make_consolidation( + session, group=group, year=2025, quarter=quarter, members=[owner, other], initiator=initiator, status=status + ) + return owner.id, version.id, initiator.id + + +async def _load_entity(maker: async_sessionmaker[AsyncSession], entity_id: uuid.UUID) -> LegalEntity: + async with maker() as session: + entity = await session.get(LegalEntity, entity_id) + assert entity is not None + return entity + + +@pytest.mark.parametrize( + ("job_status", "moved"), + [(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / RECOGNIZED (чистый) +) +async def test_mapping_moves_documents_processing_version_only_for_needs_check( + committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool +): + """«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии + 2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; привязок нет.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker) + file_id, _, _ = await _prepare( + maker, + storage, + _minimal(), + entity=await _load_entity(maker, entity_id), + job_status=job_status, + uploaded_by=user_id, + ) + + assert await process_once(maker, storage, _settings()) is True + + [document] = await _documents(maker, file_id) + assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.RECOGNIZED) + async with maker() as session: + version = await session.get(ConsolidationVersion, version_id) + assert version is not None + history = list( + ( + await session.execute( + select(ConsolidationStatusHistory) + .where(ConsolidationStatusHistory.version_id == version_id) + .order_by(ConsolidationStatusHistory.changed_at) + ) + ).scalars() + ) + bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one() + assert bindings == 0 + if moved: + assert version.status is ConsolidationStatus.DATA_CORRECTION + last = history[-1] + assert (last.from_status, last.to_status, last.changed_by) == ( + ConsolidationStatus.DOCUMENTS_PROCESSING, + ConsolidationStatus.DATA_CORRECTION, + user_id, + ) + else: + assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING + assert len(history) == FACTORY_HISTORY # создание и переход фабрики в DOCUMENTS_PROCESSING; новых записей нет + + +async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Только `DOCUMENTS_PROCESSING` реагирует на маппинг (spec): версия `INITIATED` остаётся.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, user_id = await _version_in(maker, status=ConsolidationStatus.INITIATED) + await _prepare( + maker, + storage, + _minimal(), + entity=await _load_entity(maker, entity_id), + job_status=RecognitionStatus.needs_check, + uploaded_by=user_id, + ) + + assert await process_once(maker, storage, _settings()) is True + + async with maker() as session: + version = await session.get(ConsolidationVersion, version_id) + assert version is not None + assert version.status is ConsolidationStatus.INITIATED + + +async def test_mapping_without_uploader_skips_transition_without_error( + committed_sessionmaker: async_sessionmaker[AsyncSession], +): + """`uploaded_by` NULL: маппинг завершается (файл `PROCESSED`), версия не двигается, причина — в логе.""" + maker, storage = committed_sessionmaker, FakeStorage() + entity_id, version_id, _ = await _version_in(maker) + file_id, _, _ = await _prepare( + maker, + storage, + _minimal(), + entity=await _load_entity(maker, entity_id), + job_status=RecognitionStatus.needs_check, + uploaded_by=None, + ) + messages: list[str] = [] + sink = logger.add(lambda message: messages.append(str(message)), level="INFO") + try: + assert await process_once(maker, storage, _settings()) is True + finally: + logger.remove(sink) + + assert (await _file(maker, file_id)).status is FileStatus.PROCESSED + async with maker() as session: + version = await session.get(ConsolidationVersion, version_id) + assert version is not None + assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING + assert any(str(file_id) in message and "uploaded_by" in message for message in messages) + + # --- audit-trail: обмен и маппинг не пишут audit_event (ASSUMPTION(OQ-55)) ---------------------------------------- diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 6cde4b7..d706f3a 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -154,9 +154,9 @@ - Загрузка: до приёма — `get_version(scope)` (404 `VERSION_NOT_FOUND`) → `status ∈ IN_WORK_STATUSES` (409 `VERSION_NOT_EDITABLE`) → ЮЛ ∈ `members` (422 `ENTITY_NOT_IN_PERIMETER`); после цикла по файлам, если `accepted` и статус `INITIATED` — отдельная транзакция (`session_transaction` как у загрузки файла): `lock_version` → если всё ещё `INITIATED` → `transition(to=DOCUMENTS_PROCESSING, changed_by=user)`. - `on_documents_mapped(session, *, file, documents, now) -> list[uuid.UUID]`: если `file.uploaded_by is None` → `logger.info` и `[]`; версии `DOCUMENTS_PROCESSING` с ЮЛ файла в периметре (`FOR UPDATE` по `id`); для каждой — есть документ `NEEDS_CHECK` с `fits` → `transition(to=DATA_CORRECTION, changed_by=file.uploaded_by)`; привязки не создаёт. -- [ ] **Step 1: Тесты.** Сценарии `primary-files` «Загрузка в контексте версии» (переход, запись истории), «ЮЛ вне периметра» (422, файлов нет), версия `CANCELLED` → 409, чужая версия → 404; `osv-result-mapping` «Предупреждение маппинга двигает статус», «Документ RECOGNIZED» (без перехода), `uploaded_by` NULL — без перехода и без ошибки. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_files_upload.py tests/test_result_mapping.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): переходы DOCUMENTS_PROCESSING и DATA_CORRECTION по загрузке и маппингу`. +- [x] **Step 1: Тесты.** Сценарии `primary-files` «Загрузка в контексте версии» (переход, запись истории), «ЮЛ вне периметра» (422, файлов нет), версия `CANCELLED` → 409, чужая версия → 404; `osv-result-mapping` «Предупреждение маппинга двигает статус», «Документ RECOGNIZED» (без перехода), `uploaded_by` NULL — без перехода и без ошибки. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_files_upload.py tests/test_result_mapping.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): переходы DOCUMENTS_PROCESSING и DATA_CORRECTION по загрузке и маппингу`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index a1c0dd1..c338e24 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -221,3 +221,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 34 | 2026-10-06 | `FILE_REPLACE.after_value.unbound_version_ids` — версии, где сняты привязки документов ИМЕННО этого заменённого файла (по возрастанию `id`; пустой список, если не было); `on_documents_superseded` вызывается по одному разу на заменённый файл | событие пишется на каждый заменённый файл; общий список версий по всей автозамене приписал бы файлу чужие отвязки. Повторные вызовы берут ту же advisory ГК и те же версии (уже удержаны) — порядок блокировок не меняется | нулевая | | 35 | 2026-10-06 | Выбор «самого позднего `confirmed_at`» при равенстве — по наибольшему `id`; `confirmed_at` NULL у `CONFIRMED` (только тестовые данные) считается самым ранним. `bind_confirmed` заменяет привязанный документ того же ключа победителем и не трогает уже привязанного победителя (повторный подхват — 0 новых привязок) | детерминизм; спека порядок при равенстве не задаёт | при одинаковом `confirmed_at` привязан «случайный по id» из двух — равноценных — документов | | 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции | +| 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) | +| 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет | +| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением) | +| 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга | +| 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | +| 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index d705204..9a975c4 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -35,7 +35,7 @@ `DocumentService.confirm` (версии `FOR UPDATE` после документа), `supersede_previous` (`unbound_version_ids` в аудите). Тесты: сценарии `documents-api` (привязка, период, `CANCELLED`, подхват, две версии документа), `primary-files` «Заменённый документ был в версии», порядок блокировок (подтверждение ∥ правка периметра). -- [ ] 3.2 **Статусы по событиям файлов.** `upload.py`: `consolidation_version_id` (проверки, переход после приёма); +- [x] 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`, ячейки в карточке. -- 2.47.2 From 4cc08cc3de7bf2e88c986a0bca7afb088ad6f3f5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 05:37:39 +0400 Subject: [PATCH 13/27] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20Task=207=20=E2=80=94=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B4=D0=BC=D0=BD=D0=BE=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=85=D1=83=D0=BA=D0=B0=20=D0=BC=D0=B0=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0,=20=D1=83=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B9=D1=87=D0=B8=D0=B2=D0=BE=D1=81=D1=82=D1=8C=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=BA=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BA=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T7.0: двухсессионный тест маппинг ∥ перевод версии в DOCUMENTS_PROCESSING (ловит мутант statuses={DOCUMENTS_PROCESSING}). T7.1 (R-10, design №43): ошибка перехода после загрузки логируется, принятые файлы и 201 сохраняются. T7.3: лог пропуска без uploaded_by только при наличии NEEDS_CHECK. T7.4: TransactionFactory импортируется из session_audit. T7.2/T7.5: дополнена строка design №39. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/consolidation_documents.py | 8 +-- api/src/services/primary_files.py | 47 +++++++++------- api/tests/test_api_files_upload.py | 29 ++++++++++ api/tests/test_consolidation_documents.py | 56 +++++++++++++++---- .../changes/slice-4-consolidation/design.md | 3 +- 5 files changed, 107 insertions(+), 36 deletions(-) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 4fdbd5f..dd0bbce 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -279,15 +279,15 @@ async def on_documents_mapped( заблокированы, автозамена прежних версий уже взяла advisory ЮЛ → старые файлы → advisory ГК → версии): порядок «задание → файл → документы → версии» не нарушается, версии блокируются последними. """ - if file.uploaded_by is None: + needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK] + if not needs_check: + return [] + if file.uploaded_by is None: # лог — только когда переход мог состояться (есть NEEDS_CHECK) logger.info( "Переход версий по маппингу пропущен: у файла нет uploaded_by (changed_by обязателен). file_id={file_id}", file_id=file.id, ) return [] - needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK] - if not needs_check: - return [] # цикл импортов: consolidation_status → consolidations → consolidation_documents (design №36) from src.services.consolidation_status import transition # noqa: PLC0415 diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 85e42b6..cb285dd 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -18,8 +18,6 @@ audit-trail «События файлов и документов»; design D-4) import hashlib import uuid -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from loguru import logger @@ -58,6 +56,7 @@ from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.result_mapping import RESULTS_PREFIX +from src.services.session_audit import TransactionFactory OBJECT_TYPE = "stored_file" UPLOADS_PREFIX = "uploads" @@ -69,9 +68,6 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" -#: фабрика отдельной короткой транзакции (в проде — `src.db.db.session_transaction`): commit на выходе -TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] - class StorageUnavailableError(DomainError): """503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект.""" @@ -168,7 +164,11 @@ class PrimaryFilesService: В ОТДЕЛЬНОЙ транзакции (`session_transaction`, как аудит входа): `lock_version` (`FOR UPDATE`) → статус перечитывается — если уже не `INITIATED` (параллельный переход, аннулирование), ничего не меняется. Версия блокируется на короткое время и вне транзакции запроса, чтобы загрузка не держала её на время приёма файлов - (D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет.""" + (D-9). Транзакция запроса к этому моменту версию не трогала, взаимных ожиданий нет. + + Приём файлов важнее побочного статуса (R-10, design №43): любая ошибка перехода (`Exception`) логируется и + НЕ меняет ответ — принятые файлы остаются, версия остаётся `INITIATED` до следующей загрузки или + подтверждения. `BaseException` (`asyncio.CancelledError`) не перехватывается.""" # импорт внутри функции — как в consolidation_documents (design №36): модули консолидации импортируют друг друга from src.services.consolidation_status import transition # noqa: PLC0415 @@ -177,20 +177,27 @@ class PrimaryFilesService: from src.db.db import session_transaction # noqa: PLC0415 factory = session_transaction - async with factory() as session: - version = await consolidations_repo.lock_version(session, version_id) - if version is None or version.status is not ConsolidationStatus.INITIATED: - return - await transition( - session, - AuditService(session), - version=version, - to=ConsolidationStatus.DOCUMENTS_PROCESSING, - changed_by=self._audit.user_id, - comment=None, - ip_address=self._audit.ip_address, - user_agent=self._audit.user_agent, - now=utc_now(), + try: + async with factory() as session: + version = await consolidations_repo.lock_version(session, version_id) + if version is None or version.status is not ConsolidationStatus.INITIATED: + return + await transition( + session, + AuditService(session), + version=version, + to=ConsolidationStatus.DOCUMENTS_PROCESSING, + changed_by=self._audit.user_id, + comment=None, + ip_address=self._audit.ip_address, + user_agent=self._audit.user_agent, + now=utc_now(), + ) + except Exception: + logger.exception( + "Не удалось перевести версию в DOCUMENTS_PROCESSING после загрузки, файлы приняты. " + "error_category=version_transition_error, version_id={version_id}", + version_id=version_id, ) def require_storage(self) -> ObjectStorage: diff --git a/api/tests/test_api_files_upload.py b/api/tests/test_api_files_upload.py index 5a68fbe..9d9d053 100644 --- a/api/tests/test_api_files_upload.py +++ b/api/tests/test_api_files_upload.py @@ -17,6 +17,7 @@ from typing import Any import httpx import pytest from fastapi import FastAPI +from loguru import logger from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker @@ -736,3 +737,31 @@ async def test_upload_without_version_does_not_touch_versions( assert response.status_code == HTTP_CREATED, response.text assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED assert len(storage.objects) == 1 + + +@pytest.mark.integration +async def test_upload_keeps_accepted_files_when_version_transition_fails( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, monkeypatch: pytest.MonkeyPatch +) -> None: + """R-10 (design №43): приём файлов важнее побочного статуса. Переход версии падает исключением — ответ 201, принятый + файл сохранён, версия остаётся `INITIATED`, ошибка — в логе.""" + + async def broken_transition(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("переход недоступен") + + monkeypatch.setattr("src.services.consolidation_status.transition", broken_transition) + version, (member, *_), _ = await _version(seeded) + client = await auth_client(KA_RF) + messages: list[str] = [] + sink = logger.add(lambda message: messages.append(str(message)), level="ERROR") + try: + response = await _post_in_version(client, member.id, version.id, ("osv.xls", _xls("transition-fails"))) + finally: + logger.remove(sink) + + assert response.status_code == HTTP_CREATED, response.text + [accepted] = response.json()["accepted"] + assert (await seeded.get(StoredFile, uuid.UUID(accepted["file_id"]))) is not None + assert len(storage.objects) == 1 + assert await _version_state(seeded, version.id) is ConsolidationStatus.INITIATED + assert any(str(version.id) in message and "version_transition_error" in message for message in messages) diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 700dd62..faa7ee5 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -936,6 +936,18 @@ async def test_on_documents_mapped_without_uploader_logs_and_skips(seeded: Async assert any(str(file.id) in message and "uploaded_by" in message for message in messages) +async def _mapped_in_own_transaction(maker: async_sessionmaker[AsyncSession], data: dict[str, Any]) -> list[uuid.UUID]: + """Хук маппинга файла `data["file_id"]` в своей транзакции (как цикл маппинга: файл и документы уже есть).""" + async with maker() as session, session.begin(): + file = await session.get(StoredFile, data["file_id"]) + document = await session.get(Document, data["document_id"]) + assert file is not None + assert document is not None + return await consolidation_documents.on_documents_mapped( + session, file=file, documents=[document], now=utc_now() + ) + + @pytest.mark.integration async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( committed_sessionmaker: async_sessionmaker[AsyncSession], @@ -952,21 +964,43 @@ async def test_on_documents_mapped_waits_for_member_edit_and_sees_new_member( assert file is not None file.uploaded_by = data["user_id"] - async def mapped() -> list[uuid.UUID]: - async with maker() as session, session.begin(): - file = await session.get(StoredFile, data["file_id"]) - document = await session.get(Document, data["document_id"]) - assert file is not None - assert document is not None - return await consolidation_documents.on_documents_mapped( - session, file=file, documents=[document], now=utc_now() - ) - async with maker() as editor, editor.begin(): assert await repo.lock_version(editor, data["version_id"]) is not None editor.add(ConsolidationMember(consolidation_version_id=data["version_id"], legal_entity_id=data["owner_id"])) await editor.flush() - hook = asyncio.create_task(mapped()) + hook = asyncio.create_task(_mapped_in_own_transaction(maker, data)) + await _wait_for_lock_waiter(maker) + assert not hook.done() + moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS) + + assert moved == [data["version_id"]] + async with maker() as session: + assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +async def test_on_documents_mapped_waits_for_transition_to_documents_processing( + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + """R-2/№40, маппинг ∥ загрузка (`after_upload_to_version`): версия `INITIATED` держится транзакцией, которая + переводит её в `DOCUMENTS_PROCESSING` (не зафиксировано). Хук блокирует надмножество версий «в работе» (а не только + `DOCUMENTS_PROCESSING`), поэтому встаёт в ожидание строки версии и после commit видит `DOCUMENTS_PROCESSING` — + переход в `DATA_CORRECTION` не теряется. Хук, ищущий версии только в `DOCUMENTS_PROCESSING`, снимок `INITIATED` + пропустил бы без ожидания и вернул бы `[]`.""" + maker = committed_sessionmaker + data = await _committed_setup(maker, members=2, spare=0) # владелец документа — член периметра, версия INITIATED + async with maker() as session, session.begin(): + file = await session.get(StoredFile, data["file_id"]) + assert file is not None + file.uploaded_by = data["user_id"] + + async with maker() as uploader, uploader.begin(): + version = await repo.lock_version(uploader, data["version_id"]) + assert version is not None + assert version.status is ConsolidationStatus.INITIATED + version.status = ConsolidationStatus.DOCUMENTS_PROCESSING + await uploader.flush() + hook = asyncio.create_task(_mapped_in_own_transaction(maker, data)) await _wait_for_lock_waiter(maker) assert not hook.done() moved = await asyncio.wait_for(hook, LOCK_WAIT_SECONDS) diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index c338e24..44e8358 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -223,7 +223,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции | | 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) | | 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет | -| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением) | +| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически | | 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга | | 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | | 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | +| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе | -- 2.47.2 From f46cf2fec527f01e9e2460b6ec4577a8be6ce9e3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 05:50:48 +0400 Subject: [PATCH 14/27] =?UTF-8?q?feat(consolidation):=20=D1=87=D0=B5=D0=BA?= =?UTF-8?q?-=D0=BB=D0=B8=D1=81=D1=82=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=20=D0=BF=D0=BE=D0=B4=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Домен `checklist` (Rule, CellStatus, Cell, order_rules, build) — чистые функции без ORM; ячейка (ЮЛ, правило): CONFIRMED по привязанным документам (ОСВ: нарастающий за квартал версии либо квартальные Q1..Qn, пробел → missing_quarters; ББ/ОФР: документ за квартал версии), NEEDS_CHECK/RECOGNIZED по неподтверждённому подходящему, PROCESSING, ERROR, иначе MISSING; правило ОСВ закрывает только точный код счёта. Репозиторий: rules_for, unconfirmed_documents. Сервис: checklist(), ячейки в карточке версии. Маршрут GET /consolidations/versions/{id}/checklist. Схема использует CellStatus домена (T2.8). Решения №44-49 в design.md. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../api/endpoints/consolidations/__init__.py | 3 +- .../api/endpoints/consolidations/checklist.py | 31 +++ api/src/domain/checklist.py | 218 +++++++++++++++- api/src/repositories/consolidations.py | 54 +++- api/src/schemas/consolidations.py | 5 +- api/src/services/consolidations.py | 66 ++++- api/tests/test_api_consolidation_checklist.py | 153 +++++++++++ api/tests/test_api_consolidations.py | 6 +- api/tests/test_domain_checklist.py | 244 ++++++++++++++++++ api/tests/test_openapi_routes.py | 4 + api/tests/test_repositories_consolidations.py | 64 ++++- api/tests/test_schemas_consolidations.py | 12 +- .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 6 + .../changes/slice-4-consolidation/tasks.md | 2 +- 15 files changed, 854 insertions(+), 20 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/checklist.py create mode 100644 api/tests/test_api_consolidation_checklist.py create mode 100644 api/tests/test_domain_checklist.py diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 413e891..b8c3992 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import locks, members, periods, status, versions +from src.api.endpoints.consolidations import checklist, locks, members, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -10,3 +10,4 @@ router.include_router(versions.router) router.include_router(locks.router) router.include_router(status.router) router.include_router(members.router) +router.include_router(checklist.router) diff --git a/api/src/api/endpoints/consolidations/checklist.py b/api/src/api/endpoints/consolidations/checklist.py new file mode 100644 index 0000000..92ab0bb --- /dev/null +++ b/api/src/api/endpoints/consolidations/checklist.py @@ -0,0 +1,31 @@ +"""`GET /consolidations/versions/{id}/checklist` — чек-лист комплекта документов версии (spec consolidations-api +«Чек-лист комплекта»; design D-13). Право проверяется до видимости; версия ГК вне видимости — тот же 404.""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import ChecklistOut + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}/checklist", + response_model=ChecklistOut, + dependencies=VIEW, + responses=NOT_FOUND, + summary="Чек-лист комплекта документов версии", +) +async def get_checklist(version_id: uuid.UUID, service: ConsolidationServiceDep) -> ChecklistOut: + """Правила комплекта подхода версии и строки по ЮЛ периметра: ячейки `{status, missing_quarters, document_ids}`.""" + return await service.checklist(version_id) diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index a8e97b9..9dd9198 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -1,13 +1,50 @@ -"""Чек-лист комплекта документов версии консолидации (spec consolidations-api «Чек-лист комплекта»; §9.1). +"""Чек-лист комплекта документов версии консолидации (spec consolidations-api «Чек-лист комплекта»; §9.1; D-13). -Чистые функции без БД и ORM (design D-13). Сейчас — только состояние файлов ЮЛ, которое репозиторий отдаёт -вычислению ячейки (`PROCESSING` — есть файл в работе, `ERROR` — последний файл ЮЛ в ошибке); неподтверждённые -документы-кандидаты и сборка ячеек добавляются в задаче 3.3 (рулинг R-5). +Чистые функции без БД и ORM: документы и ЮЛ приходят структурно (Protocol'ы), состояние файлов ЮЛ — `EntityFilesState` +(`PROCESSING` — есть файл в работе, `ERROR` — последний файл ЮЛ в ошибке). Статус ячейки (ЮЛ, правило): + +1. `CONFIRMED` — пара полностью покрыта привязанными документами (ОСВ — выбор источников без пробела; ББ/ОФР — + документ за квартал версии); +2. `NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый подходящий документ того же типа и счёта (приоритет + `NEEDS_CHECK`); +3. `PROCESSING` — у ЮЛ есть файл `UPLOADED`/`PROCESSING` (по всем недостающим ячейкам, решение №6); +4. `ERROR` — последний файл ЮЛ в `ERROR`; +5. иначе `MISSING`. + +Правило ОСВ закрывается только документом с `osv_account_code`, равным `account` правила (OQ-04): субсчёт 58.03 не +закрывает 58. «Подходящесть» неподтверждённых документов (`fits`: член периметра, год, квартал) проверяет +вызывающий — в `unbound` приходят уже подходящие. """ +import enum +import re +import uuid +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass +from typing import Protocol, TypeVar -from src.models.enums import FileStatus +from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter + +__all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"] + +_QUARTERS = tuple(PeriodQuarter) +_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(_QUARTERS)} +# порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце +_TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2} +_UNORDERED = len(_TYPE_ORDER) +_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1} + + +class CellStatus(enum.StrEnum): + """Статус ячейки чек-листа (единственное определение: схема API берёт его отсюда).""" + + CONFIRMED = "CONFIRMED" + NEEDS_CHECK = "NEEDS_CHECK" + RECOGNIZED = "RECOGNIZED" + PROCESSING = "PROCESSING" + ERROR = "ERROR" + MISSING = "MISSING" @dataclass(frozen=True) @@ -17,3 +54,174 @@ class EntityFilesState: has_in_progress: bool last_file_status: FileStatus | None + + +@dataclass(frozen=True) +class Rule: + """Правило комплекта (`required_document_rule`): тип документа, код счёта ОСВ (`None` — не ОСВ), обязательность.""" + + document_type: DocumentType + account: str | None + is_mandatory: bool + + +@dataclass(frozen=True) +class Cell: + """Ячейка чек-листа: статус, недостающие кварталы квартальной ОСВ (при частичном покрытии) и привязанные + документы, покрывающие пару полностью (`CONFIRMED`) или частично (пробел).""" + + status: CellStatus + missing_quarters: tuple[PeriodQuarter, ...] = () + document_ids: tuple[uuid.UUID, ...] = () + + +class EntityLike(Protocol): + """ЮЛ периметра (структурный тип: `LegalEntity` подходит).""" + + @property + def id(self) -> uuid.UUID: ... + + +class DocumentLike(Protocol): + """Документ (структурный тип: `Document` подходит); поля — как в DBML `document`.""" + + @property + def id(self) -> uuid.UUID: ... + @property + def legal_entity_id(self) -> uuid.UUID: ... + @property + def document_type(self) -> DocumentType: ... + @property + def osv_account_code(self) -> str | None: ... + @property + def period_quarter(self) -> PeriodQuarter | None: ... + @property + def is_cumulative(self) -> bool | None: ... + @property + def status(self) -> DocumentStatus: ... + + +def _natural_key(code: str) -> tuple[tuple[int, int, str], ...]: + """Натуральный ключ кода счёта: числовые части — по значению (`01 < 58 < 58.03 < 60`), прочие — по тексту.""" + return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in re.split(r"[.\s]+", code) if part) + + +def order_rules(rules: Iterable[Rule]) -> list[Rule]: + """Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта.""" + return sorted( + rules, + key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), _natural_key(rule.account or "")), + ) + + +@dataclass(frozen=True) +class _Coverage: + """Покрытие пары привязанными документами: полное или частичное (пробел в квартальных ОСВ).""" + + complete: bool + missing_quarters: tuple[PeriodQuarter, ...] + document_ids: tuple[uuid.UUID, ...] + + +def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage: + """Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий + приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов. + + Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал, + квартальный позже версии) покрытия не дают. + + # Task 9 заменит на consolidation_periods.select_sources + """ + cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter] + if cumulative: + return _Coverage(True, (), tuple(d.id for d in cumulative)) + last = _QUARTER_ORDER[version_quarter] + needed = _QUARTERS[: last + 1] + by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list) + for document in documents: + if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter: + by_quarter[document.period_quarter].append(document) + present = [d.id for quarter in needed for d in by_quarter.get(quarter, [])] + missing = tuple(quarter for quarter in needed if quarter not in by_quarter) + if not missing: + return _Coverage(True, (), tuple(present)) + return _Coverage(False, missing if present else (), tuple(present)) + + +def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage: + if rule.document_type == DocumentType.OSV: + return _osv_coverage(version_quarter, documents) + # ББ/ОФР: документ за квартал версии (прочие типы — так же) + matched = tuple(d.id for d in documents if d.period_quarter == version_quarter) + return _Coverage(bool(matched), (), matched) + + +def _same_slot(document: DocumentLike, rule: Rule) -> bool: + return document.document_type == rule.document_type and ( + rule.document_type != DocumentType.OSV or document.osv_account_code == rule.account + ) + + +def _cell( + version_quarter: PeriodQuarter, + rule: Rule, + bound: Sequence[DocumentLike], + unbound: Sequence[DocumentLike], + state: EntityFilesState, +) -> Cell: + coverage = _coverage(version_quarter, rule, [d for d in bound if _same_slot(d, rule)]) + if coverage.complete: + return Cell(CellStatus.CONFIRMED, (), coverage.document_ids) + + def partial(status: CellStatus) -> Cell: + return Cell(status, coverage.missing_quarters, coverage.document_ids) + + candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_PRIORITY] + if candidates: + best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status]) + return partial(CellStatus(best.status.value)) + if state.has_in_progress: + return partial(CellStatus.PROCESSING) + if state.last_file_status == FileStatus.ERROR: + return partial(CellStatus.ERROR) + return partial(CellStatus.MISSING) + + +E = TypeVar("E", bound=EntityLike) + + +def build( # noqa: PLR0913 - сигнатура зафиксирована планом: правила, квартал, ЮЛ, привязанные, кандидаты, файлы + rules: Sequence[Rule], + *, + version_quarter: PeriodQuarter, + members: Sequence[E], + bound: Sequence[DocumentLike], + unbound: Sequence[DocumentLike], + files_state: Mapping[uuid.UUID, EntityFilesState], +) -> list[tuple[E, list[Cell]]]: + """Ячейки чек-листа по каждому ЮЛ `members` (порядок сохраняется) в порядке `rules` (порядок столбцов задаёт + вызывающий — `order_rules`). `bound` — документы, привязанные к версии; `unbound` — неподтверждённые подходящие + версии документы (`RECOGNIZED`/`NEEDS_CHECK`); ЮЛ без записи в `files_state` — без файлов.""" + idle = EntityFilesState(has_in_progress=False, last_file_status=None) + bound_by: dict[uuid.UUID, list[DocumentLike]] = defaultdict(list) + for document in bound: + bound_by[document.legal_entity_id].append(document) + unbound_by: dict[uuid.UUID, list[DocumentLike]] = defaultdict(list) + for document in unbound: + unbound_by[document.legal_entity_id].append(document) + return [ + ( + member, + [ + _cell( + version_quarter, + rule, + bound_by.get(member.id, []), + unbound_by.get(member.id, []), + files_state.get(member.id, idle), + ) + for rule in rules + ], + ) + for member in members + ] diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 65e44f7..da066d2 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState from src.domain.policy import VisibilityScope, company_groups_visible from src.models.access import AppUser +from src.models.accounting import ChartOfAccount +from src.models.audit_ex import RequiredDocumentRule from src.models.consolidation import ( Consolidation, ConsolidationDocument, @@ -25,13 +27,22 @@ from src.models.consolidation import ( ConsolidationVersion, ) from src.models.directories import CompanyGroup, LegalEntity -from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.enums import ( + ConsolidationApproach, + ConsolidationStatus, + DocumentStatus, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, +) from src.models.files import Document, StoredFile from src.models.osv import OsvEntry FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2) SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM) +UNCONFIRMED_STATUSES = (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK) def _visible_consolidations(scope: VisibilityScope) -> Select[tuple[Consolidation]]: @@ -289,3 +300,44 @@ async def entity_files_state( entity_id: EntityFilesState(has_in_progress=entity_id in busy, last_file_status=last_status.get(entity_id)) for entity_id in states } + + +async def rules_for( + session: AsyncSession, approach: ConsolidationApproach +) -> list[tuple[RequiredDocumentRule, str | None]]: + """Правила комплекта подхода (`required_document_rule`) и код счёта правила (`chart_of_account.code`; у ББ/ОФР — + `None`). Порядок столбцов задаёт домен (`checklist.order_rules`); здесь — детерминированный по `id`.""" + stmt = ( + select(RequiredDocumentRule, ChartOfAccount.code) + .outerjoin(ChartOfAccount, ChartOfAccount.id == RequiredDocumentRule.account_id) + .where(RequiredDocumentRule.approach == approach) + .order_by(RequiredDocumentRule.id) + ) + return [(row[0], row[1]) for row in await session.execute(stmt)] + + +async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int) -> list[Document]: + """Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED`; любых типов (R-5). + + Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`. + """ + if not entity_ids: + return [] + stmt = ( + select(Document) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id.in_(list(entity_ids)), + Document.period_year == year, + Document.status.in_(UNCONFIRMED_STATUSES), + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by( + Document.legal_entity_id, + Document.document_type, + Document.osv_account_code, + Document.period_quarter, + Document.id, + ) + ) + return list((await session.execute(stmt)).scalars()) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 9cee00a..14e8228 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -12,6 +12,7 @@ from typing import Literal from pydantic import BaseModel, Field +from src.domain.checklist import CellStatus from src.models.enums import ( ConsolidationApproach, ConsolidationScenario, @@ -28,8 +29,6 @@ MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 # признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] -# статус ячейки чек-листа (spec consolidations-api «Чек-лист комплекта») -ChecklistCellStatus = Literal["CONFIRMED", "NEEDS_CHECK", "RECOGNIZED", "PROCESSING", "ERROR", "MISSING"] class RefOut(BaseModel): @@ -93,7 +92,7 @@ class ConsolidationPeriodOut(BaseModel): class ChecklistCellOut(BaseModel): """Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы.""" - status: ChecklistCellStatus + status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8) missing_quarters: list[PeriodQuarter] document_ids: list[uuid.UUID] diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index aa75be9..5080738 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain import checklist as checklist_domain from src.domain.consolidation_periods import reporting_date from src.domain.policy import FINISHED_STATUSES, VisibilityScope from src.models.access import AppUser @@ -39,6 +40,10 @@ from src.repositories import consolidations as repo from src.repositories import directories as directories_repo from src.repositories.users import UserWithAccess from src.schemas.consolidations import ( + ChecklistCellOut, + ChecklistOut, + ChecklistRowOut, + ChecklistRuleOut, ConsolidationCreate, ConsolidationPeriodOut, HistoryOut, @@ -161,6 +166,60 @@ class ConsolidationService: raise NotFoundError(VERSION_NOT_FOUND) return await self._card(*found) + async def checklist(self, version_id: uuid.UUID) -> ChecklistOut: + """Чек-лист комплекта версии: правила подхода (`BALANCE`, `INCOME`, ОСВ по коду счёта) и строки по ЮЛ периметра + (по имени) с ячейками в порядке правил; версия вне видимости и несуществующая — один и тот же 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + entities = [entity for _, entity in await repo.members(self._session, version.id)] + rules, rows = await self._checklist_cells(version, consolidation, entities) + return ChecklistOut( + rules=[ + ChecklistRuleOut(document_type=rule.document_type, account=rule.account, is_mandatory=rule.is_mandatory) + for rule in rules + ], + rows=[ + ChecklistRowOut( + legal_entity_id=entity.id, + legal_entity_name=entity.name, + legal_entity_inn=entity.inn, + cells=[self._cell_out(cell) for cell in cells], + ) + for entity, cells in rows + ], + ) + + async def _checklist_cells( + self, version: ConsolidationVersion, consolidation: Consolidation, entities: list[LegalEntity] + ) -> tuple[list[checklist_domain.Rule], list[tuple[LegalEntity, list[checklist_domain.Cell]]]]: + """Правила подхода версии и ячейки по каждому ЮЛ `entities` (D-13): привязанные документы версии, + неподтверждённые подходящие (`fits`) и состояние файлов ЮЛ. Кандидаты — `repo.unconfirmed_documents`, + фильтр `fits` — здесь.""" + rules = checklist_domain.order_rules( + checklist_domain.Rule(rule.document_type, account, rule.is_mandatory) + for rule, account in await repo.rules_for(self._session, version.approach) + ) + entity_ids = [entity.id for entity in entities] + candidates = await repo.unconfirmed_documents(self._session, entity_ids, consolidation.year) + unbound = [doc for doc in candidates if consolidation_documents.fits(doc, version, consolidation, entity_ids)] + rows = checklist_domain.build( + rules, + version_quarter=consolidation.quarter, + members=entities, + bound=await repo.bound_documents(self._session, version.id), + unbound=unbound, + files_state=await repo.entity_files_state(self._session, entity_ids), + ) + return rules, rows + + @staticmethod + def _cell_out(cell: checklist_domain.Cell) -> ChecklistCellOut: + return ChecklistCellOut( + status=cell.status, missing_quarters=list(cell.missing_quarters), document_ids=list(cell.document_ids) + ) + async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard: """Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии. @@ -285,7 +344,7 @@ class ConsolidationService: async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard: """Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка. - Чек-лист у членов — пустой список до Task 8 (`domain.checklist.build`). + Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода. """ group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) initiator = await self._session.get(AppUser, version.initiator_id) @@ -296,6 +355,7 @@ class ConsolidationService: locks = await repo.active_locks(self._session, [version.id]) history = await repo.history(self._session, version.id) brief = self._brief(version, locks.get(version.id), len(members)) + _, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members]) return VersionCard( **brief.model_dump(), consolidation_id=consolidation.id, @@ -314,9 +374,9 @@ class ConsolidationService: name=entity.name, inn=entity.inn, is_head_entity=member.is_head_entity, - checklist=[], # Task 8: ячейки по правилам подхода + checklist=[self._cell_out(cell) for cell in cells[position][1]], ) - for member, entity in members + for position, (member, entity) in enumerate(members) ], history=[ HistoryOut( diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py new file mode 100644 index 0000000..298b56e --- /dev/null +++ b/api/tests/test_api_consolidation_checklist.py @@ -0,0 +1,153 @@ +"""API чек-листа комплекта версии (spec consolidations-api «Чек-лист комплекта»; design D-13): правила подхода BASIC +из сида, ячейки по привязанным документам, карточка с теми же ячейками, права и видимость.""" + +import uuid +from typing import Any + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_document, + make_legal_entity, + make_stored_file, +) + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +KA_RF = "ka_rf@armka.local" +CURATOR = "curator_go@armka.local" +FOREIGN = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +EXPECTED_RULES = [ + ("BALANCE", None), + ("INCOME", None), + *[("OSV", account) for account in ("01", "58", "60", "62", "66", "67", "76", "90")], +] +OSV_60, OSV_90 = 4, 9 # позиции столбцов в `rules` + + +async def _world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]: + """Версия РБ Q3 2024 ГК Ростовского филиала с двумя ЮЛ: «А» (имя первым) и «Б».""" + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group: CompanyGroup = await make_company_group(session, name="ГК Русский бисквит", office=office) + entities = [await make_legal_entity(session, name=name, group=group) for name in ("ООО А", "ООО Б")] + user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation(session, group=group, year=YEAR, quarter=Q3, members=entities, initiator=user) + return version, entities + + +async def _bound_osv( + session: AsyncSession, version: ConsolidationVersion, entity: LegalEntity, account: str, quarter: PeriodQuarter +) -> uuid.UUID: + document = await make_confirmed_osv( + session, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=False, rows=[] + ) + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + return document.id + + +def _cells(body: dict[str, Any], name: str) -> list[dict[str, Any]]: + return next(row["cells"] for row in body["rows"] if row["legal_entity_name"] == name) + + +@pytest.mark.integration +async def test_checklist_osv_60_confirmed_osv_90_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, second) = await _world(seeded) + documents = [await _bound_osv(seeded, version, first, "60", quarter) for quarter in (Q1, Q2, Q3)] + client = await auth_client(KA_RF) + + response = await client.get(f"/consolidations/versions/{version.id}/checklist") + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert [(rule["document_type"], rule["account"]) for rule in body["rules"]] == EXPECTED_RULES + assert all(rule["is_mandatory"] is True for rule in body["rules"]) + assert [row["legal_entity_name"] for row in body["rows"]] == ["ООО А", "ООО Б"] + assert body["rows"][0]["legal_entity_id"] == str(first.id) + assert body["rows"][1]["legal_entity_inn"] == second.inn + cells = _cells(body, "ООО А") + assert len(cells) == len(EXPECTED_RULES) + assert cells[OSV_60] == {"status": "CONFIRMED", "missing_quarters": [], "document_ids": [str(i) for i in documents]} + assert cells[OSV_90] == {"status": "MISSING", "missing_quarters": [], "document_ids": []} + assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"} + + +@pytest.mark.integration +async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, second) = await _world(seeded) + first_q1 = await _bound_osv(seeded, version, first, "60", Q1) + third_q3 = await _bound_osv(seeded, version, first, "60", Q3) + # ОСВ 62 первого ЮЛ — на проверке (не подтверждена); у второго ЮЛ файл в работе + file, job = await make_stored_file( + seeded, entity=first, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + await make_document( + seeded, file=file, job=job, account="62", year=YEAR, quarter=Q3, status=DocumentStatus.NEEDS_CHECK + ) + await make_stored_file(seeded, entity=second, status=FileStatus.PROCESSING) + client = await auth_client(KA_RF) + + body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + + first_cells = _cells(body, "ООО А") + assert first_cells[OSV_60] == { + "status": "MISSING", + "missing_quarters": ["Q2"], + "document_ids": [str(first_q1), str(third_q3)], + } + assert first_cells[OSV_60 + 1]["status"] == "NEEDS_CHECK" # 62 + assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"PROCESSING"} + + +@pytest.mark.integration +async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + version, (first, _) = await _bound_world(seeded) + client = await auth_client(KA_RF) + + checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + card = (await client.get(f"/consolidations/versions/{version.id}")).json() + + assert [member["name"] for member in card["members"]] == [row["legal_entity_name"] for row in checklist["rows"]] + for member, row in zip(card["members"], checklist["rows"], strict=True): + assert member["checklist"] == row["cells"] + assert card["members"][0]["checklist"][OSV_60]["status"] == "CONFIRMED" + assert card["members"][0]["id"] == str(first.id) + + +async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]: + version, entities = await _world(session) + for quarter in (Q1, Q2, Q3): + await _bound_osv(session, version, entities[0], "60", quarter) + return version, entities + + +@pytest.mark.integration +async def test_checklist_visible_to_go_curator_hidden_from_foreign_office( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + version, _ = await _world(seeded) + curator = await auth_client(CURATOR) + foreign = await auth_client(FOREIGN) + admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет + url = f"/consolidations/versions/{version.id}/checklist" + + assert (await curator.get(url)).status_code == HTTP_OK + hidden = await foreign.get(url) + unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/checklist") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (await admin.get(url)).status_code == HTTP_FORBIDDEN diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index db24a60..8bc64f6 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -37,6 +37,7 @@ HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 +CHECKLIST_COLUMNS = 10 # ББ, ОФР и ОСВ 01, 58, 60, 62, 66, 67, 76, 90 ROSTOV = "Ростовский региональный филиал" KRASNODAR = "Краснодарский региональный филиал" KA_RF = "ka_rf@armka.local" @@ -101,7 +102,10 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s assert card["initiator_office"] == {"id": str(user.regional_office_id), "name": ROSTOV} assert [member["name"] for member in card["members"]] == ["ООО Член 0", "ООО Член 1"] assert {member["id"] for member in card["members"]} == {str(entity.id) for entity in entities} - assert all(member["is_head_entity"] is False and member["checklist"] == [] for member in card["members"]) + assert all(member["is_head_entity"] is False for member in card["members"]) + # чек-лист по правилам подхода BASIC (ББ, ОФР, ОСВ по 8 счетам): документов нет — все ячейки MISSING + expected = ["MISSING"] * CHECKLIST_COLUMNS + assert all([cell["status"] for cell in member["checklist"]] == expected for member in card["members"]) assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [(None, "INITIATED")] assert card["history"][0]["changed_by"] == str(user.id) diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py new file mode 100644 index 0000000..57c8ec6 --- /dev/null +++ b/api/tests/test_domain_checklist.py @@ -0,0 +1,244 @@ +"""Чек-лист комплекта (Срез 4, задача 3.3; D-13): чистая сборка ячеек без БД, документы — `Document` без сессии.""" + +import uuid + +import pytest + +from src.domain.checklist import Cell, CellStatus, EntityFilesState, Rule, build, order_rules +from src.models.directories import LegalEntity +from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document + +Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 +OSV_60 = Rule(DocumentType.OSV, "60", True) +OSV_58 = Rule(DocumentType.OSV, "58", True) +BALANCE = Rule(DocumentType.BALANCE, None, True) +IDLE = EntityFilesState(has_in_progress=False, last_file_status=None) + + +def _entity() -> LegalEntity: + return LegalEntity(id=uuid.uuid4(), name="ООО", inn="7700000000") + + +def _doc( # noqa: PLR0913 - тестовая фабрика документа + entity: LegalEntity, + *, + kind: DocumentType = DocumentType.OSV, + account: str | None = "60", + quarter: PeriodQuarter | None = Q3, + cumulative: bool | None = False, + status: DocumentStatus = DocumentStatus.CONFIRMED, +) -> Document: + return Document( + id=uuid.uuid4(), + legal_entity_id=entity.id, + document_type=kind, + osv_account_code=account, + period_year=2024, + period_quarter=quarter, + is_cumulative=cumulative, + status=status, + ) + + +def _cell( # noqa: PLR0913 - тестовая обёртка над `build` для одного ЮЛ и одного правила + rule: Rule, + entity: LegalEntity, + *, + version_quarter: PeriodQuarter = Q3, + bound: list[Document] | None = None, + unbound: list[Document] | None = None, + state: EntityFilesState = IDLE, +) -> Cell: + result = build( + [rule], + version_quarter=version_quarter, + members=[entity], + bound=bound or [], + unbound=unbound or [], + files_state={entity.id: state}, + ) + return result[0][1][0] + + +def test_gap_in_quarters_is_missing_with_present_documents() -> None: + entity = _entity() + first, third = _doc(entity, quarter=Q1), _doc(entity, quarter=Q3) + + cell = _cell(OSV_60, entity, bound=[third, first]) + + assert cell.status == CellStatus.MISSING + assert cell.missing_quarters == (Q2,) + assert cell.document_ids == (first.id, third.id) # по порядку кварталов + + +def test_all_quarterly_documents_confirm() -> None: + entity = _entity() + documents = [_doc(entity, quarter=quarter) for quarter in (Q1, Q2, Q3)] + + cell = _cell(OSV_60, entity, bound=documents) + + assert cell == Cell(CellStatus.CONFIRMED, (), tuple(d.id for d in documents)) + + +def test_cumulative_of_version_quarter_beats_quarterly_with_gap() -> None: + entity = _entity() + cumulative = _doc(entity, quarter=Q3, cumulative=True) + quarterly = _doc(entity, quarter=Q3, cumulative=False) + + cell = _cell(OSV_60, entity, bound=[quarterly, cumulative]) + + assert cell == Cell(CellStatus.CONFIRMED, (), (cumulative.id,)) + + +def test_cumulative_of_other_quarter_does_not_cover() -> None: + entity = _entity() + + cell = _cell(OSV_60, entity, bound=[_doc(entity, quarter=Q2, cumulative=True)]) + + assert cell == Cell(CellStatus.MISSING) + + +def test_q1_version_quarterly_q1_equals_cumulative() -> None: + entity = _entity() + document = _doc(entity, quarter=Q1, cumulative=False) + + cell = _cell(OSV_60, entity, version_quarter=Q1, bound=[document]) + + assert cell == Cell(CellStatus.CONFIRMED, (), (document.id,)) + + +def test_quarterly_later_than_version_does_not_count() -> None: + entity = _entity() + + cell = _cell(OSV_60, entity, version_quarter=Q2, bound=[_doc(entity, quarter=Q1), _doc(entity, quarter=Q4)]) + + assert cell.status == CellStatus.MISSING + assert cell.missing_quarters == (Q2,) + assert len(cell.document_ids) == 1 + + +def test_subaccount_does_not_close_account() -> None: + entity = _entity() + + cell = _cell(OSV_58, entity, bound=[_doc(entity, account="58.03", quarter=Q3, cumulative=True)]) + + assert cell == Cell(CellStatus.MISSING) + + +def test_unconfirmed_subaccount_does_not_make_needs_check() -> None: + entity = _entity() + + cell = _cell( + OSV_58, entity, unbound=[_doc(entity, account="58.03", status=DocumentStatus.NEEDS_CHECK, cumulative=True)] + ) + + assert cell.status == CellStatus.MISSING + + +def test_needs_check_has_priority_over_recognized() -> None: + entity = _entity() + recognized = _doc(entity, status=DocumentStatus.RECOGNIZED) + needs_check = _doc(entity, status=DocumentStatus.NEEDS_CHECK) + + assert _cell(OSV_60, entity, unbound=[recognized, needs_check]).status == CellStatus.NEEDS_CHECK + assert _cell(OSV_60, entity, unbound=[recognized]).status == CellStatus.RECOGNIZED + + +def test_unconfirmed_of_other_type_or_account_is_ignored() -> None: + entity = _entity() + other = [ + _doc(entity, account="62", status=DocumentStatus.NEEDS_CHECK), + _doc(entity, kind=DocumentType.BALANCE, account=None, status=DocumentStatus.NEEDS_CHECK), + ] + + assert _cell(OSV_60, entity, unbound=other).status == CellStatus.MISSING + + +def test_needs_check_keeps_partial_coverage_of_bound() -> None: + entity = _entity() + first = _doc(entity, quarter=Q1) + + cell = _cell(OSV_60, entity, bound=[first], unbound=[_doc(entity, status=DocumentStatus.NEEDS_CHECK)]) + + assert cell == Cell(CellStatus.NEEDS_CHECK, (Q2, Q3), (first.id,)) + + +def test_processing_for_any_missing_cell_when_file_in_progress() -> None: + entity = _entity() + busy = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.PROCESSING) + + assert _cell(OSV_60, entity, state=busy) == Cell(CellStatus.PROCESSING) + assert _cell(BALANCE, entity, state=busy) == Cell(CellStatus.PROCESSING) + + +def test_processing_does_not_override_confirmed_or_unconfirmed() -> None: + entity = _entity() + busy = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.PROCESSING) + document = _doc(entity, quarter=Q3, cumulative=True) + + assert _cell(OSV_60, entity, bound=[document], state=busy).status == CellStatus.CONFIRMED + unconfirmed = _doc(entity, status=DocumentStatus.RECOGNIZED) + assert _cell(OSV_60, entity, unbound=[unconfirmed], state=busy).status == CellStatus.RECOGNIZED + + +def test_error_when_last_file_failed_and_processing_wins_over_error() -> None: + entity = _entity() + failed = EntityFilesState(has_in_progress=False, last_file_status=FileStatus.ERROR) + both = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) + processed = EntityFilesState(has_in_progress=False, last_file_status=FileStatus.PROCESSED) + + assert _cell(OSV_60, entity, state=failed) == Cell(CellStatus.ERROR) + assert _cell(OSV_60, entity, state=both).status == CellStatus.PROCESSING + assert _cell(OSV_60, entity, state=processed).status == CellStatus.MISSING + + +def test_balance_of_version_quarter_is_confirmed() -> None: + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q3, cumulative=None) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.CONFIRMED, (), (balance.id,)) + + +def test_balance_of_q2_at_version_q3_is_missing() -> None: + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q2, cumulative=None) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) + + +def test_documents_of_other_entity_are_ignored() -> None: + entity, stranger = _entity(), _entity() + foreign = _doc(stranger, quarter=Q3, cumulative=True) + + assert _cell(OSV_60, entity, bound=[foreign]).status == CellStatus.MISSING + + +def test_build_keeps_members_and_rules_order_and_defaults_files_state() -> None: + first, second = _entity(), _entity() + rules = [BALANCE, OSV_58, OSV_60] + document = _doc(second, account="58", quarter=Q3, cumulative=True) + + result = build(rules, version_quarter=Q3, members=[first, second], bound=[document], unbound=[], files_state={}) + + assert [member for member, _ in result] == [first, second] + assert [cell.status for cell in result[0][1]] == [CellStatus.MISSING] * 3 + assert [cell.status for cell in result[1][1]] == [CellStatus.MISSING, CellStatus.CONFIRMED, CellStatus.MISSING] + + +def test_order_rules_balance_income_then_osv_by_natural_code() -> None: + income = Rule(DocumentType.INCOME, None, True) + osv = [Rule(DocumentType.OSV, code, True) for code in ("90", "58", "01", "76", "60", "58.03", "9")] + + ordered = order_rules([*osv, income, BALANCE]) + + assert [(r.document_type, r.account) for r in ordered] == [ + (DocumentType.BALANCE, None), + (DocumentType.INCOME, None), + *[(DocumentType.OSV, code) for code in ("01", "9", "58", "58.03", "60", "76", "90")], + ] + + +@pytest.mark.parametrize("status", list(CellStatus)) +def test_cell_status_is_str_enum(status: CellStatus) -> None: + assert status == status.value diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index e240f13..e1a78a0 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -58,6 +58,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"}, "/api/v1/consolidations/versions/{version_id}/status": {"post"}, "/api/v1/consolidations/versions/{version_id}/members": {"put"}, + "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -131,6 +132,9 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any assert {"200", "404"} <= set(card) listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"] assert {"200", "404"} <= set(listing) + checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"] + assert {"200", "404"} <= set(checklist) + assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: diff --git a/api/tests/test_repositories_consolidations.py b/api/tests/test_repositories_consolidations.py index 84e1308..600cb18 100644 --- a/api/tests/test_repositories_consolidations.py +++ b/api/tests/test_repositories_consolidations.py @@ -15,12 +15,23 @@ from src.domain.policy import VisibilityScope from src.models.access import AppUser from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice -from src.models.enums import ConsolidationStatus, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter +from src.models.enums import ( + ConsolidationApproach, + ConsolidationStatus, + DocumentStatus, + DocumentType, + FileStatus, + OsvDataType, + OsvGroupType, + PeriodQuarter, + RecognitionStatus, +) from src.repositories import consolidations as repo from tests.factories import ( make_company_group, make_confirmed_osv, make_consolidation, + make_document, make_legal_entity, make_lock, make_regional_office, @@ -391,6 +402,57 @@ async def test_entity_files_state_in_progress_and_last_status(db_session: AsyncS assert await repo.entity_files_state(db_session, []) == {} +@pytest.mark.integration +async def test_rules_for_basic_and_extended_from_seed(seeded: AsyncSession) -> None: + basic = await repo.rules_for(seeded, ConsolidationApproach.BASIC) + extended = await repo.rules_for(seeded, ConsolidationApproach.EXTENDED) + + assert {(rule.document_type, account) for rule, account in basic} == { + (DocumentType.BALANCE, None), + (DocumentType.INCOME, None), + *[(DocumentType.OSV, code) for code in ("01", "58", "60", "62", "66", "67", "76", "90")], + } + assert all(rule.approach == ConsolidationApproach.BASIC and rule.is_mandatory for rule, _ in basic) + assert extended + assert {rule.approach for rule, _ in extended} == {ConsolidationApproach.EXTENDED} + + +@pytest.mark.integration +async def test_unconfirmed_documents_filters_status_year_entity_and_superseded(db_session: AsyncSession) -> None: + group = await make_company_group(db_session) + entity = await make_legal_entity(db_session, group=group) + other = await make_legal_entity(db_session, group=group) + + async def doc( + owner: LegalEntity, + *, + status: DocumentStatus, + year: int = 2024, + file_status: FileStatus = FileStatus.PROCESSED, + account: str = "60", + ) -> uuid.UUID: + file, job = await make_stored_file( + db_session, entity=owner, status=file_status, job_status=RecognitionStatus.done + ) + document = await make_document( + db_session, file=file, job=job, account=account, year=year, quarter=PeriodQuarter.Q3, status=status + ) + return document.id + + needs_check = await doc(entity, status=DocumentStatus.NEEDS_CHECK) + recognized = await doc(entity, status=DocumentStatus.RECOGNIZED, account="58") + await doc(entity, status=DocumentStatus.CONFIRMED) # подтверждённый — не кандидат + await doc(entity, status=DocumentStatus.ERROR) + await doc(entity, status=DocumentStatus.NEEDS_CHECK, year=2023) + await doc(entity, status=DocumentStatus.NEEDS_CHECK, file_status=FileStatus.SUPERSEDED) + await doc(other, status=DocumentStatus.NEEDS_CHECK) + + found = await repo.unconfirmed_documents(db_session, [entity.id], 2024) + + assert sorted(d.id for d in found) == sorted([needs_check, recognized]) + assert await repo.unconfirmed_documents(db_session, [], 2024) == [] + + @pytest.mark.integration async def test_repository_returns_selectable_models(db_session: AsyncSession) -> None: """Дымовая проверка: список версий ЮЛ после `make_consolidation` читается обычным запросом.""" diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 8006eb5..92068a8 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -7,9 +7,10 @@ from decimal import Decimal import pytest from pydantic import ValidationError -from src.domain.checklist import EntityFilesState +from src.domain.checklist import CellStatus, EntityFilesState from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter from src.schemas.consolidations import ( + ChecklistCellOut, ConsolidatedRowOut, ConsolidationCreate, MapCounterpartyIn, @@ -79,3 +80,12 @@ def test_entity_files_state_is_frozen() -> None: state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) with pytest.raises(dataclasses.FrozenInstanceError): state.has_in_progress = False # type: ignore[misc] + + +def test_checklist_cell_status_is_the_domain_enum() -> None: + """Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются.""" + for status in CellStatus: + cell = ChecklistCellOut(status=status.value, missing_quarters=[], document_ids=[]) # type: ignore[arg-type] + assert cell.status is status + with pytest.raises(ValidationError): + ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index d706f3a..06a9a3d 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -170,9 +170,9 @@ - `domain/checklist.py`: `@dataclass(frozen=True) class Rule: document_type: DocumentType; account: str | None; is_mandatory: bool`; `class CellStatus(StrEnum): CONFIRMED, NEEDS_CHECK, RECOGNIZED, PROCESSING, ERROR, MISSING`; `@dataclass(frozen=True) class Cell: status; missing_quarters: tuple[PeriodQuarter, ...]; document_ids: tuple[uuid.UUID, ...]`; `def build(rules, *, version_quarter, members: Sequence[LegalEntity], bound: Sequence[Document], unbound: Sequence[Document], files_state: Mapping[uuid.UUID, EntityFilesState]) -> list[tuple[LegalEntity, list[Cell]]]` — для ОСВ использует `consolidation_periods.select_sources` (Task 9; до него — локальная проверка «нарастающий за квартал версии или все кварталы Q1..Qn»; в Task 9 заменить на `select_sources`), для ББ/ОФР — документ за квартал версии. - Порядок правил: `BALANCE`, `INCOME`, затем ОСВ по `account` (натуральная сортировка кодов `01 < 58 < 60`). -- [ ] **Step 1: Тесты.** Домен: пробел Q2 (`MISSING`, `["Q2"]`, два `document_ids`), субсчёт 58.03 не закрывает 58, `NEEDS_CHECK` при неподтверждённом подходящем, `PROCESSING` при файле в работе, `ERROR`, ББ за квартал версии `CONFIRMED`, ББ за Q2 при версии Q3 — `MISSING`. API: `GET …/checklist` на версии РБ с тремя ОСВ 60 → ячейка ОСВ 60 `CONFIRMED`, ОСВ 90 `MISSING`; карточка содержит те же ячейки; `curator_go` читает. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): чек-лист комплекта документов по правилам подхода`. +- [x] **Step 1: Тесты.** Домен: пробел Q2 (`MISSING`, `["Q2"]`, два `document_ids`), субсчёт 58.03 не закрывает 58, `NEEDS_CHECK` при неподтверждённом подходящем, `PROCESSING` при файле в работе, `ERROR`, ББ за квартал версии `CONFIRMED`, ББ за Q2 при версии Q3 — `MISSING`. API: `GET …/checklist` на версии РБ с тремя ОСВ 60 → ячейка ОСВ 60 `CONFIRMED`, ОСВ 90 `MISSING`; карточка содержит те же ячейки; `curator_go` читает. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): чек-лист комплекта документов по правилам подхода`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 44e8358..9ee8820 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -228,3 +228,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | | 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | | 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе | +| 44 | 2026-10-06 | Статус ячейки — одно определение: `domain.checklist.CellStatus` (StrEnum); `ChecklistCellOut.status` в схеме API использует его, `Literal` `ChecklistCellStatus` удалён (T2.8) | два расходящихся определения — источник тихого рассинхрона домена и контракта | нулевая: OpenAPI-enum тот же набор значений | +| 45 | 2026-10-06 | Проверка «подходящести» неподтверждённых документов (`fits`: член периметра, год, квартал) выполняется в сервисе (`_checklist_cells`), в домен `build` приходят уже подходящие; домен сам сверяет только ЮЛ, тип, счёт и статус (`RECOGNIZED`/`NEEDS_CHECK`) | `fits` живёт в `services.consolidation_documents` (домен не импортирует сервисы и ORM); дублировать предикат в домене — риск расхождения с привязкой | если `fits` изменится — меняется и чек-лист, что желательно; вне `fits` домен кандидатов не отсеивает | +| 46 | 2026-10-06 | Выбор источников ОСВ в чек-листе — локальная функция `_osv_coverage` (нарастающий за квартал версии, иначе квартальные Q1..Qn все; нарастающий приоритетнее; пробел → `missing_quarters` + документы имеющихся кварталов), помечена `# Task 9 заменит на consolidation_periods.select_sources`; при полном отсутствии документов `missing_quarters = []` (перечисляются только при частичном покрытии, как в спеке) | `select_sources` появится в Task 9; план прямо разрешает локальную проверку до него | до Task 9 два места с правилом выбора; Task 9 удаляет локальное | +| 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) | +| 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая | +| 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 9a975c4..b8ab252 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -38,7 +38,7 @@ - [x] 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`, ячейки в карточке. +- [x] 3.3 **Чек-лист.** `domain/checklist.py:build(rules, members, documents, files)` (чистая), `GET …/checklist`, ячейки в карточке. Тесты: пробел Q2, субсчёт, `PROCESSING`, `ERROR`, ББ/ОФР за квартал версии. ## 4. Сводная ОСВ -- 2.47.2 From d5a317a0407564a85820e3a2fe03cda5e6e6eab4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 06:07:55 +0400 Subject: [PATCH 15/27] =?UTF-8?q?feat(domain):=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=9E=D0=A1=D0=92=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?=D1=8B,=20=D0=BF=D1=80=D0=B8=D0=B7=D0=BD=D0=B0=D0=BA=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consolidation_periods: QUARTER_ORDER (единственное определение), quarters_through, counts_as_cumulative (R-1), select_sources -> Sources | Gap (D-2) - consolidated_osv: build (склейка квартальных по ключу, нарастающий как есть, пробелы, counterparty_id последнего квартала + warnings, дорезолв без записи, признак GSK/GK/EXTERNAL, порядок D-16), apply_filter, summary, classify - checklist: _osv_coverage — адаптер над select_sources (T8.1), явная таблица статусов кандидатов (T8.4), QUARTER_ORDER/account_sort_key из домена (T8.5) - consolidation_documents: QUARTER_ORDER и counts_as_cumulative из домена - schemas: CounterpartyType — доменный StrEnum (T2.8) - design.md: решения №50–56; план и tasks.md 4.1 отмечены Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/checklist.py | 58 +- api/src/domain/consolidated_osv.py | 505 ++++++++++++++++++ api/src/domain/consolidation_periods.py | 93 +++- api/src/domain/osv_mapping.py | 9 + api/src/schemas/consolidations.py | 4 +- api/src/services/consolidation_documents.py | 8 +- api/tests/test_domain_consolidated_osv.py | 499 +++++++++++++++++ .../test_domain_consolidation_periods.py | 99 +++- api/tests/test_schemas_consolidations.py | 10 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 7 + .../changes/slice-4-consolidation/tasks.md | 2 +- 12 files changed, 1252 insertions(+), 48 deletions(-) create mode 100644 api/src/domain/consolidated_osv.py create mode 100644 api/tests/test_domain_consolidated_osv.py diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index 9dd9198..1cd24cf 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -17,23 +17,21 @@ """ import enum -import re import uuid from collections import defaultdict from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Protocol, TypeVar +from src.domain.consolidation_periods import Sources, quarters_through, select_sources +from src.domain.osv_mapping import account_sort_key from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter __all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"] -_QUARTERS = tuple(PeriodQuarter) -_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(_QUARTERS)} # порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце _TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2} _UNORDERED = len(_TYPE_ORDER) -_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1} class CellStatus(enum.StrEnum): @@ -47,6 +45,15 @@ class CellStatus(enum.StrEnum): MISSING = "MISSING" +# неподтверждённый документ → статус ячейки (T8.4: явная таблица вместо равенства строк); порядок ключей — приоритет: +# `NEEDS_CHECK` важнее `RECOGNIZED` +_UNCONFIRMED_STATUS: dict[DocumentStatus, CellStatus] = { + DocumentStatus.NEEDS_CHECK: CellStatus.NEEDS_CHECK, + DocumentStatus.RECOGNIZED: CellStatus.RECOGNIZED, +} +_UNCONFIRMED_PRIORITY = {status: position for position, status in enumerate(_UNCONFIRMED_STATUS)} + + @dataclass(frozen=True) class EntityFilesState: """Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус @@ -101,16 +108,11 @@ class DocumentLike(Protocol): def status(self) -> DocumentStatus: ... -def _natural_key(code: str) -> tuple[tuple[int, int, str], ...]: - """Натуральный ключ кода счёта: числовые части — по значению (`01 < 58 < 58.03 < 60`), прочие — по тексту.""" - return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in re.split(r"[.\s]+", code) if part) - - def order_rules(rules: Iterable[Rule]) -> list[Rule]: """Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта.""" return sorted( rules, - key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), _natural_key(rule.account or "")), + key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), account_sort_key(rule.account or "")), ) @@ -124,28 +126,20 @@ class _Coverage: def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage: - """Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий - приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов. + """Покрытие ОСВ одного счёта ЮЛ — адаптер над `consolidation_periods.select_sources` (D-2, T8.1). - Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал, - квартальный позже версии) покрытия не дают. - - # Task 9 заменит на consolidation_periods.select_sources + Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле + `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке + кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46). """ - cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter] - if cumulative: - return _Coverage(True, (), tuple(d.id for d in cumulative)) - last = _QUARTER_ORDER[version_quarter] - needed = _QUARTERS[: last + 1] - by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list) - for document in documents: - if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter: - by_quarter[document.period_quarter].append(document) - present = [d.id for quarter in needed for d in by_quarter.get(quarter, [])] - missing = tuple(quarter for quarter in needed if quarter not in by_quarter) - if not missing: - return _Coverage(True, (), tuple(present)) - return _Coverage(False, missing if present else (), tuple(present)) + selected = select_sources(version_quarter, documents) + if isinstance(selected, Sources): + return _Coverage(True, (), tuple(d.id for d in selected.documents)) + needed = quarters_through(version_quarter) + present = tuple( + d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter + ) + return _Coverage(False, selected.missing_quarters if present else (), present) def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage: @@ -176,10 +170,10 @@ def _cell( def partial(status: CellStatus) -> Cell: return Cell(status, coverage.missing_quarters, coverage.document_ids) - candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_PRIORITY] + candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_STATUS] if candidates: best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status]) - return partial(CellStatus(best.status.value)) + return partial(_UNCONFIRMED_STATUS[best.status]) if state.has_in_progress: return partial(CellStatus.PROCESSING) if state.last_file_status == FileStatus.ERROR: diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py new file mode 100644 index 0000000..def4de7 --- /dev/null +++ b/api/src/domain/consolidated_osv.py @@ -0,0 +1,505 @@ +"""Сводная ОСВ версии консолидации (spec consolidated-osv; design D-1, D-2, D-3, D-16; §2, §3 `05-domain-rules.md`). + +Чистая функция от версии (DECISION-5): результат не хранится. Вход — структурные dataclass'ы без ORM (`SourceRow` — +строка `osv_entry` `group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`; `SourceDocument` — привязанный к версии +ОСВ-документ; `Entity` — ЮЛ справочника), справочник для дорезолва — `Resolver` (`CounterpartyIndex` подходит). + +1. Пары (ЮЛ периметра, счёт документа) → `consolidation_periods.select_sources`: нарастающий за квартал версии — + строки «как есть» (`is_cumulative = True`, дубли ключа не схлопываются, суммы `None` остаются `None`); квартальные + `Q1..Qn` — склейка по ключу (счёт строки, имя `strip()`, ИНН без пробелов): `opening_*` из `Q1`, `turnover_*` = + Σ, `closing_*` из `Qn`, отсутствующая в квартале строка и сумма `None` — нули, дубли ключа в квартале суммируются; + пробел квартала → `GapItem`, строк по паре нет. +2. Контрагент: непустой `counterparty_id` самого позднего квартала (расхождение с другими — в `warnings`, решение + №8); пустой → `Resolver.resolve(inn, name)` на лету, `match_stored = False`, в БД не пишется. +3. Признак (`classify`): `GSK` — контрагент в периметре версии; `GK` — его ГК = ГК консолидации; иначе `EXTERNAL`. +4. Порядок строк: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`. + +Деньги — `Decimal`, `float` не используется. +""" + +import enum +import uuid +from collections import defaultdict +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from decimal import Decimal +from typing import Protocol + +from src.domain.consolidation_periods import Gap, quarters_through, select_sources +from src.domain.inn import normalize_inn +from src.domain.osv_mapping import Resolution, account_sort_key +from src.models.enums import CounterpartyMatchKind, PeriodQuarter + +__all__ = [ + "AUTO_MATCH_KINDS", + "Consolidated", + "ConsolidatedRow", + "CounterpartyType", + "Entity", + "GapItem", + "OsvFilter", + "Resolver", + "SourceDocument", + "SourceRow", + "apply_filter", + "build", + "classify", + "glue_key", + "summary", +] + +ZERO = Decimal("0") +#: Способы автоматического сопоставления по имени — счётчик `auto_matched_total` (независимо от `match_stored`). +AUTO_MATCH_KINDS = frozenset({CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS}) + + +class CounterpartyType(enum.StrEnum): + """Признак контрагента относительно версии (§3 п. 5; единственное определение: схема API берёт его отсюда).""" + + GSK = "GSK" # ЮЛ периметра версии (consolidation_member) + GK = "GK" # ЮЛ той же ГК, вне периметра + EXTERNAL = "EXTERNAL" # прочие, в том числе несопоставленные + + +@dataclass(frozen=True) +class Entity: + """ЮЛ справочника: имя, ИНН и ГК (`None` — монозаёмщик).""" + + id: uuid.UUID + name: str + inn: str + company_group_id: uuid.UUID | None + + +@dataclass(frozen=True) +class SourceDocument: + """ОСВ-документ, привязанный к версии (`consolidation_document`); поля — как в DBML `document`.""" + + id: uuid.UUID + legal_entity_id: uuid.UUID + osv_account_code: str | None + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + + +@dataclass(frozen=True) +class SourceRow: + """Строка `osv_entry` (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`) — как хранится.""" + + id: uuid.UUID + document_id: uuid.UUID + row_number: int | None + account_code: str | None + account_root: str | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + match_kind: CounterpartyMatchKind | None + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + + +@dataclass(frozen=True) +class ConsolidatedRow: + """Строка сводной ОСВ (D-16): исходные `osv_entry`, ЮЛ, счёт, период, контрагент как в файле и после резолва, + признак, способ сопоставления, шесть сумм, предупреждения.""" + + entry_ids: tuple[uuid.UUID, ...] + legal_entity_id: uuid.UUID + legal_entity_name: str + legal_entity_inn: str + account_code: str | None + account_root: str | None + period: str # "<год> Q" + is_cumulative: bool + row_number: int | None + counterparty_name: str | None + counterparty_inn: str | None + counterparty_id: uuid.UUID | None + counterparty_entity_name: str | None + counterparty_type: CounterpartyType + match_kind: CounterpartyMatchKind | None + match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class GapItem: + """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за `missing_quarters` — строк по паре нет.""" + + legal_entity_id: uuid.UUID + legal_entity_name: str + account_code: str + missing_quarters: tuple[PeriodQuarter, ...] + + +@dataclass(frozen=True) +class Consolidated: + """Свод версии: строки в порядке D-16, пробелы (по имени ЮЛ и счёту), число привязанных ОСВ-документов.""" + + rows: tuple[ConsolidatedRow, ...] + gaps: tuple[GapItem, ...] + documents_total: int + + +class Resolver(Protocol): + """Справочник контрагентов для дорезолва (§3 п. 4): ИНН → точное имя → нормализованное имя/алиас.""" + + def resolve(self, inn: str | None, name: str | None) -> Resolution | None: ... + + +@dataclass(frozen=True) +class OsvFilter: + """Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс + ИНН контрагента; `unmapped` — `counterparty_id` пуст после дорезолва (`False` — только сопоставленные).""" + + legal_entity_id: uuid.UUID | None = None + account_root: str | None = None + counterparty_type: CounterpartyType | None = None + unmapped: bool | None = None + match_kinds: frozenset[CounterpartyMatchKind] = frozenset() + q: str | None = None + + +GlueKey = tuple[str, str, str] + + +def glue_key(row: SourceRow) -> GlueKey: + """Ключ склейки квартальных строк (D-2): счёт строки, имя контрагента без краевых пробелов, ИНН без пробелов.""" + return (row.account_code or "", (row.counterparty_name or "").strip(), normalize_inn(row.counterparty_inn or "")) + + +def classify( + counterparty_id: uuid.UUID | None, + *, + members: Mapping[uuid.UUID, Entity], + entities: Mapping[uuid.UUID, Entity], + group_id: uuid.UUID, +) -> CounterpartyType: + """Признак контрагента относительно версии (D-3): `GSK` — в периметре; `GK` — ЮЛ ГК консолидации вне периметра; + иначе (пустой, чужая ГК, монозаёмщик, неизвестный id) — `EXTERNAL`.""" + if counterparty_id is None: + return CounterpartyType.EXTERNAL + if counterparty_id in members: + return CounterpartyType.GSK + entity = entities.get(counterparty_id) + if entity is not None and entity.company_group_id == group_id: + return CounterpartyType.GK + return CounterpartyType.EXTERNAL + + +# --------------------------------------------------------------------------- суммы + + +@dataclass(frozen=True) +class _Amounts: + """Шесть сумм строки; `None` при сложении — ноль.""" + + opening_debit: Decimal = ZERO + opening_credit: Decimal = ZERO + turnover_debit: Decimal = ZERO + turnover_credit: Decimal = ZERO + closing_debit: Decimal = ZERO + closing_credit: Decimal = ZERO + + @classmethod + def of(cls, row: SourceRow) -> "_Amounts": + return cls( + row.opening_debit or ZERO, + row.opening_credit or ZERO, + row.turnover_debit or ZERO, + row.turnover_credit or ZERO, + row.closing_debit or ZERO, + row.closing_credit or ZERO, + ) + + @classmethod + def total(cls, rows: Iterable[SourceRow]) -> "_Amounts": + result = cls() + for row in rows: + part = cls.of(row) + result = cls( + result.opening_debit + part.opening_debit, + result.opening_credit + part.opening_credit, + result.turnover_debit + part.turnover_debit, + result.turnover_credit + part.turnover_credit, + result.closing_debit + part.closing_debit, + result.closing_credit + part.closing_credit, + ) + return result + + +@dataclass(frozen=True) +class _Values: + """Суммы строки свода: у нарастающего — как в файле (с `None`), у склейки — `Decimal`.""" + + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None + + +def _as_is(row: SourceRow) -> _Values: + return _Values( + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + ) + + +def _glued(first: _Amounts, turnover: _Amounts, last: _Amounts) -> _Values: + """§2: `opening_*` — из первого квартала, `turnover_*` — сумма, `closing_*` — из последнего.""" + return _Values( + first.opening_debit, + first.opening_credit, + turnover.turnover_debit, + turnover.turnover_credit, + last.closing_debit, + last.closing_credit, + ) + + +# --------------------------------------------------------------------------- сборка строки + + +@dataclass(frozen=True) +class _Context: + period: str + group_id: uuid.UUID + members: Mapping[uuid.UUID, Entity] + entities: Mapping[uuid.UUID, Entity] + resolver: Resolver + + +QuarterRows = Sequence[tuple[PeriodQuarter, Sequence[SourceRow]]] + + +def _row_order(row: SourceRow) -> tuple[bool, int, uuid.UUID]: + """Порядок строк внутри документа/квартала: номер строки (пустой — в конце), затем `id` (детерминизм).""" + return (row.row_number is None, row.row_number or 0, row.id) + + +def _stored_counterparty(quarters: QuarterRows) -> tuple[SourceRow | None, tuple[str, ...]]: + """Строка-источник `counterparty_id` (D-3, решение №8): первая сопоставленная в самом позднем квартале, где они + есть; строки с другим непустым `counterparty_id` — в `warnings` (пустые — не расхождение).""" + mapped = [(quarter, row) for quarter, rows in quarters for row in rows if row.counterparty_id is not None] + if not mapped: + return None, () + latest = mapped[-1][0] + chosen = next(row for quarter, row in mapped if quarter == latest) + warnings = tuple( + f"{quarter.value}: строка {row.id} сопоставлена с {row.counterparty_id}, в своде — {chosen.counterparty_id}" + for quarter, row in mapped + if row.counterparty_id != chosen.counterparty_id + ) + return chosen, warnings + + +def _compose( # noqa: PLR0913 - сборка строки свода из частей: ЮЛ, источники, представитель, суммы, признак склейки + context: _Context, + entity: Entity, + quarters: QuarterRows, + representative: SourceRow, + values: _Values, + *, + is_cumulative: bool, +) -> ConsolidatedRow: + """Строка свода: «как в файле» — из `representative`; контрагент — хранимый или дорезолв на лету.""" + stored, warnings = _stored_counterparty(quarters) + counterparty_id: uuid.UUID | None + match_kind: CounterpartyMatchKind | None + if stored is not None: + counterparty_id, match_kind, match_stored = stored.counterparty_id, stored.match_kind, True + else: + # дорезолв на чтение (D-3): результат показывается, но не пишется + resolution = context.resolver.resolve(representative.counterparty_inn, representative.counterparty_name) + counterparty_id = resolution.entity_id if resolution is not None else None + match_kind = resolution.kind if resolution is not None else None + match_stored = False + counterparty = None + if counterparty_id is not None: + counterparty = context.entities.get(counterparty_id) or context.members.get(counterparty_id) + return ConsolidatedRow( + entry_ids=tuple(row.id for _, rows in quarters for row in rows), + legal_entity_id=entity.id, + legal_entity_name=entity.name, + legal_entity_inn=entity.inn, + account_code=representative.account_code, + account_root=representative.account_root, + period=context.period, + is_cumulative=is_cumulative, + row_number=representative.row_number, + counterparty_name=representative.counterparty_name, + counterparty_inn=representative.counterparty_inn, + counterparty_id=counterparty_id, + counterparty_entity_name=counterparty.name if counterparty is not None else None, + counterparty_type=classify( + counterparty_id, members=context.members, entities=context.entities, group_id=context.group_id + ), + match_kind=match_kind, + match_stored=match_stored, + opening_debit=values.opening_debit, + opening_credit=values.opening_credit, + turnover_debit=values.turnover_debit, + turnover_credit=values.turnover_credit, + closing_debit=values.closing_debit, + closing_credit=values.closing_credit, + warnings=warnings, + ) + + +def _cumulative_rows( + context: _Context, + entity: Entity, + documents: Sequence[SourceDocument], + rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], +) -> list[ConsolidatedRow]: + """Нарастающий документ: строка свода = строка файла (§3 п. 3, 6), суммы как есть.""" + result = [] + for document in documents: + quarter = document.period_quarter or PeriodQuarter.Q1 # select_sources отдаёт только документы с кварталом + for row in rows_by_document.get(document.id, ()): + result.append(_compose(context, entity, [(quarter, [row])], row, _as_is(row), is_cumulative=True)) + return result + + +def _glued_rows( + context: _Context, + entity: Entity, + needed: Sequence[PeriodQuarter], + documents: Sequence[SourceDocument], + rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], +) -> list[ConsolidatedRow]: + """Квартальные документы `Q1..Qn`: склейка по `glue_key` (§2, D-2, решение №1).""" + by_key: dict[GlueKey, dict[PeriodQuarter, list[SourceRow]]] = defaultdict(lambda: defaultdict(list)) + for document in documents: + if document.period_quarter is None: # select_sources отдаёт только документы с кварталом + continue + for row in rows_by_document.get(document.id, ()): + by_key[glue_key(row)][document.period_quarter].append(row) + result = [] + first, last = needed[0], needed[-1] + for per_quarter in by_key.values(): + quarters = [(q, sorted(per_quarter[q], key=_row_order)) for q in needed if q in per_quarter] + # «как в файле» и номер строки — из последнего квартала, где ключ есть (минимальный номер строки в нём) + representative = quarters[-1][1][0] + values = _glued( + _Amounts.total(per_quarter.get(first, ())), + _Amounts.total(row for _, rows in quarters for row in rows), + _Amounts.total(per_quarter.get(last, ())), + ) + result.append(_compose(context, entity, quarters, representative, values, is_cumulative=False)) + return result + + +def _row_sort_key(row: ConsolidatedRow) -> tuple[object, ...]: + """D-16: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`.""" + code = row.account_code or "" + return ( + row.legal_entity_name, + row.legal_entity_id, + account_sort_key(code), + code, + row.row_number is None, + row.row_number or 0, + row.entry_ids[0], + ) + + +def build( # noqa: PLR0913 - сигнатура зафиксирована планом: период, ГК, периметр, справочник, документы, строки + *, + year: int, + quarter: PeriodQuarter, + group_id: uuid.UUID, + members: Mapping[uuid.UUID, Entity], + entities: Mapping[uuid.UUID, Entity], + documents: Sequence[SourceDocument], + rows: Sequence[SourceRow], + resolver: Resolver, +) -> Consolidated: + """Свод версии за `year`/`quarter` ГК `group_id` с периметром `members` (D-1…D-3). + + `documents` — привязанные к версии ОСВ-документы (документы не членов периметра защитно игнорируются), + `rows` — их строки (строки документов вне `documents` игнорируются), `entities` — весь справочник без policy + (имя и ГК контрагента). Входной порядок на результат не влияет. + """ + context = _Context(f"{year} {quarter.value}", group_id, members, entities, resolver) + rows_by_document: dict[uuid.UUID, list[SourceRow]] = defaultdict(list) + for row in rows: + rows_by_document[row.document_id].append(row) + for document_rows in rows_by_document.values(): + document_rows.sort(key=_row_order) + pairs: dict[tuple[uuid.UUID, str], list[SourceDocument]] = defaultdict(list) + for document in sorted(documents, key=lambda d: d.id): + if document.legal_entity_id in members: + pairs[(document.legal_entity_id, document.osv_account_code or "")].append(document) + + result: list[ConsolidatedRow] = [] + gaps: list[GapItem] = [] + for (entity_id, account), pair_documents in pairs.items(): + entity = members[entity_id] + selected = select_sources(quarter, pair_documents) + if isinstance(selected, Gap): + gaps.append(GapItem(entity.id, entity.name, account, selected.missing_quarters)) + elif selected.cumulative: + result.extend(_cumulative_rows(context, entity, selected.documents, rows_by_document)) + else: + needed = quarters_through(quarter) + result.extend(_glued_rows(context, entity, needed, selected.documents, rows_by_document)) + result.sort(key=_row_sort_key) + gaps.sort(key=lambda g: (g.legal_entity_name, g.legal_entity_id, account_sort_key(g.account_code), g.account_code)) + return Consolidated(rows=tuple(result), gaps=tuple(gaps), documents_total=len(documents)) + + +# --------------------------------------------------------------------------- фильтры и сводка + + +def _matches_query(row: ConsolidatedRow, q: str | None) -> bool: + text = (q or "").strip() + if not text: + return True + if text.casefold() in (row.counterparty_name or "").casefold(): + return True + return row.counterparty_inn is not None and normalize_inn(row.counterparty_inn).startswith(normalize_inn(text)) + + +def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - по одному выходу на фильтр + if f.legal_entity_id is not None and row.legal_entity_id != f.legal_entity_id: + return False + if f.account_root is not None and row.account_root != f.account_root: + return False + if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type: + return False + if f.unmapped is not None and (row.counterparty_id is None) != f.unmapped: + return False + if f.match_kinds and row.match_kind not in f.match_kinds: + return False + return _matches_query(row, f.q) + + +def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[ConsolidatedRow]: + """Строки, прошедшие все заданные фильтры (порядок сохраняется).""" + return [row for row in rows if _matches(row, f)] + + +def summary(c: Consolidated) -> tuple[int, int, int]: + """Сводка свода: `rows_total`, `unmapped_total` (`counterparty_id` пуст после дорезолва), `auto_matched_total` + (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" + unmapped = sum(1 for row in c.rows if row.counterparty_id is None) + auto_matched = sum(1 for row in c.rows if row.match_kind in AUTO_MATCH_KINDS) + return len(c.rows), unmapped, auto_matched diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py index 3783760..4948bed 100644 --- a/api/src/domain/consolidation_periods.py +++ b/api/src/domain/consolidation_periods.py @@ -1,13 +1,31 @@ -"""Период консолидации: отчётная дата по году и кварталу (spec consolidations-api «Создание консолидации»). +"""Период консолидации: отчётная дата и выбор источников ОСВ (spec consolidations-api «Создание консолидации»; +spec consolidated-osv «Выбор источников и приведение периодов»; design D-2; §2 `05-domain-rules.md`). -Чистая функция без БД. ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. +Чистые функции без БД и ORM: документы приходят структурно (`DocLike` — `Document`, `SourceDocument`, тестовые +dataclass'ы). ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. """ import datetime +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Generic, Protocol, TypeVar from src.models.enums import PeriodQuarter -__all__ = ["reporting_date"] +__all__ = [ + "QUARTER_ORDER", + "DocLike", + "Gap", + "Sources", + "counts_as_cumulative", + "quarters_through", + "reporting_date", + "select_sources", +] + +#: Порядок кварталов года — единственное определение (T8.5): чек-лист и привязка документов берут его отсюда. +QUARTER_ORDER: tuple[PeriodQuarter, ...] = (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4) # квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года _REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = { @@ -25,3 +43,72 @@ def reporting_date(year: int, quarter: PeriodQuarter) -> datetime.date: """ month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40) return datetime.date(year + year_shift, month, 1) + + +def quarters_through(quarter: PeriodQuarter) -> tuple[PeriodQuarter, ...]: + """Кварталы года от `Q1` до `quarter` включительно (квартальные ОСВ, нужные версии за `quarter`).""" + return QUARTER_ORDER[: QUARTER_ORDER.index(quarter) + 1] + + +def counts_as_cumulative(is_cumulative: bool | None) -> bool: + """Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда с начала года, решение №10, R-1); + квартальный — только явный `False`.""" + return is_cumulative is not False + + +class DocLike(Protocol): + """Документ-кандидат в источники (структурный тип): поля — как в DBML `document`.""" + + @property + def id(self) -> uuid.UUID: ... + @property + def period_quarter(self) -> PeriodQuarter | None: ... + @property + def is_cumulative(self) -> bool | None: ... + + +D = TypeVar("D", bound=DocLike) + + +@dataclass(frozen=True) +class Sources(Generic[D]): + """Источники пары (ЮЛ, счёт): нарастающий документ за квартал версии (`cumulative`) либо квартальные `Q1..Qn` + в порядке кварталов.""" + + documents: tuple[D, ...] + cumulative: bool + + +@dataclass(frozen=True) +class Gap: + """Пробел пары (ЮЛ, счёт): нет квартальных ОСВ за `missing_quarters` (в порядке кварталов), строк по паре нет.""" + + missing_quarters: tuple[PeriodQuarter, ...] + + +def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> Sources[D] | Gap: + """Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03). + + 1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные + рядом игнорируются (нарастающий полнее по определению). + 2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный + `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала — + порядок входа). + 3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`. + + Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие + документы не привязываются, D-5; функция их защитно игнорирует). Дублей на квартал привязка не допускает (R-1): + если они всё же пришли, возвращаются все. + """ + cumulative = tuple( + d for d in documents if counts_as_cumulative(d.is_cumulative) and d.period_quarter == version_quarter + ) + if cumulative: + return Sources(documents=cumulative, cumulative=True) + needed = quarters_through(version_quarter) + quarterly = [d for d in documents if d.is_cumulative is False and d.period_quarter in needed] + missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly)) + if missing: + return Gap(missing_quarters=missing) + ordered = tuple(d for quarter in needed for d in quarterly if d.period_quarter == quarter) + return Sources(documents=ordered, cumulative=False) diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 06119e0..7f7e030 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -33,6 +33,7 @@ __all__ = [ "MappedSheet", "Resolution", "account_root", + "account_sort_key", "build_entries", "counterparty_check", "document_fields", @@ -51,6 +52,7 @@ _NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}") _ROOT_SEGMENT_RE = re.compile(r"[0-9]+") _NON_DIGITS_RE = re.compile(r"[^0-9]") _SEGMENT_SEPARATOR = "." +_ACCOUNT_SPLIT_RE = re.compile(r"[.\s]+") @dataclass(frozen=True) @@ -126,6 +128,13 @@ def account_root(code: str) -> str | None: return first if _ROOT_SEGMENT_RE.fullmatch(first) else None +def account_sort_key(code: str) -> tuple[tuple[int, int, str], ...]: + """Натуральный ключ сортировки кода счёта: числовые сегменты — по значению (`01 < 9 < 58 < 58.03 < 60 < 60.2 < + 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48).""" + parts = (part for part in _ACCOUNT_SPLIT_RE.split(code) if part) + return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts) + + def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: return ( row.opening_debit, diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 14e8228..79a6396 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -8,11 +8,11 @@ import datetime import uuid from decimal import Decimal -from typing import Literal from pydantic import BaseModel, Field from src.domain.checklist import CellStatus +from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8) from src.models.enums import ( ConsolidationApproach, ConsolidationScenario, @@ -27,8 +27,6 @@ YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 -# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний -CounterpartyType = Literal["GSK", "GK", "EXTERNAL"] class RefOut(BaseModel): diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index dd0bbce..301a110 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -27,6 +27,7 @@ from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession +from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative from src.domain.policy import IN_WORK_STATUSES from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -45,7 +46,6 @@ __all__ = [ #: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6). MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING}) -_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(PeriodQuarter)} BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool] @@ -56,8 +56,8 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда за период с начала года, решение №10).""" - return document.is_cumulative is not False + """Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий.""" + return counts_as_cumulative(document.is_cumulative) def _key(document: Document) -> BindingKey: @@ -92,7 +92,7 @@ def fits( return False if _cumulative(document): return document.period_quarter == consolidation.quarter - return _QUARTER_ORDER[document.period_quarter] <= _QUARTER_ORDER[consolidation.quarter] + return QUARTER_ORDER.index(document.period_quarter) <= QUARTER_ORDER.index(consolidation.quarter) async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py new file mode 100644 index 0000000..f2ed683 --- /dev/null +++ b/api/tests/test_domain_consolidated_osv.py @@ -0,0 +1,499 @@ +"""Сводная ОСВ версии (Срез 4, задача 4.1; spec consolidated-osv; D-1, D-2, D-3, D-16): чистая функция без БД.""" + +import dataclasses +import random +import uuid +from collections.abc import Sequence +from decimal import Decimal + +import pytest + +from src.domain.consolidated_osv import ( + Consolidated, + ConsolidatedRow, + CounterpartyType, + Entity, + GapItem, + OsvFilter, + SourceDocument, + SourceRow, + apply_filter, + build, + classify, + summary, +) +from src.domain.osv_mapping import Resolution +from src.models.enums import CounterpartyMatchKind, PeriodQuarter + +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +INN, NAME, ALIAS, MANUAL, PROPAGATED = ( + CounterpartyMatchKind.INN, + CounterpartyMatchKind.NAME, + CounterpartyMatchKind.ALIAS, + CounterpartyMatchKind.MANUAL, + CounterpartyMatchKind.PROPAGATED, +) +GSK, GK, EXTERNAL = CounterpartyType.GSK, CounterpartyType.GK, CounterpartyType.EXTERNAL +ZERO = Decimal("0") + +GROUP = uuid.uuid4() +RB = Entity(uuid.uuid4(), "АО Русский бисквит", "3528052718", GROUP) +SCH = Entity(uuid.uuid4(), "ООО Пекарня Счастливочная", "3528000001", GROUP) +CHKF = Entity(uuid.uuid4(), "ООО ЧКФ", "3528000002", GROUP) # в ГК, не в периметре версии +OUTSIDER = Entity(uuid.uuid4(), "ООО Внешний поставщик", "7700000003", uuid.uuid4()) +LONE = Entity(uuid.uuid4(), "ИП Монозаёмщик", "770000000004", None) +MEMBERS = {RB.id: RB, SCH.id: SCH} +ENTITIES = {entity.id: entity for entity in (RB, SCH, CHKF, OUTSIDER, LONE)} + + +class _Resolver: + """Справочник для дорезолва: ответы по (ИНН, имя), журнал вызовов.""" + + def __init__(self, answers: dict[tuple[str | None, str | None], Resolution] | None = None) -> None: + self.answers = answers or {} + self.calls: list[tuple[str | None, str | None]] = [] + + def resolve(self, inn: str | None, name: str | None) -> Resolution | None: + self.calls.append((inn, name)) + return self.answers.get((inn, name)) + + +def _doc( + entity: Entity = RB, account: str = "60", quarter: PeriodQuarter = Q3, *, cumulative: bool | None = False +) -> SourceDocument: + return SourceDocument(uuid.uuid4(), entity.id, account, quarter, cumulative) + + +_AMOUNT_FIELDS = { + "od": "opening_debit", + "oc": "opening_credit", + "td": "turnover_debit", + "tc": "turnover_credit", + "cd": "closing_debit", + "cc": "closing_credit", +} + + +def _amounts(**short: str | None) -> dict[str, Decimal | None]: + """Суммы строки по коротким именам (`od`, `oc`, `td`, `tc`, `cd`, `cc`): не заданные — ноль, `None` — пусто.""" + assert set(short) <= set(_AMOUNT_FIELDS), set(short) - set(_AMOUNT_FIELDS) + return { + field: None if (value := short.get(key, "0")) is None else Decimal(value) + for key, field in _AMOUNT_FIELDS.items() + } + + +def _row( # noqa: PLR0913 - тестовая фабрика строки osv_entry + document: SourceDocument, + name: str | None = "А", + *, + inn: str | None = None, + row_number: int | None = 1, + account_code: str | None = None, + counterparty_id: uuid.UUID | None = None, + match_kind: CounterpartyMatchKind | None = None, + **amounts: str | None, +) -> SourceRow: + code = account_code if account_code is not None else document.osv_account_code + return SourceRow( + id=uuid.uuid4(), + document_id=document.id, + row_number=row_number, + account_code=code, + account_root=(code or "").split(".")[0] or None, + counterparty_name=name, + counterparty_inn=inn, + counterparty_id=counterparty_id, + match_kind=match_kind, + **_amounts(**amounts), + ) + + +def _build( + documents: Sequence[SourceDocument], + rows: Sequence[SourceRow], + *, + quarter: PeriodQuarter = Q3, + resolver: _Resolver | None = None, +) -> Consolidated: + return build( + year=2024, + quarter=quarter, + group_id=GROUP, + members=MEMBERS, + entities=ENTITIES, + documents=documents, + rows=rows, + resolver=resolver or _Resolver(), + ) + + +def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]: + return ( + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + ) + + +# --------------------------------------------------------------------------- приведение периодов + + +def test_three_quarters_are_glued_into_cumulative_row() -> None: + """Сценарий «Три квартала»: opening из Q1, turnover = Σ, closing из Q3; entry_ids — три источника.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + r1 = _row(q1, od="100", td="200", cd="300") + r2 = _row(q2, od="300", td="150", cd="450") + r3 = _row(q3, od="450", td="50", cd="500") + result = _build([q3, q1, q2], [r3, r2, r1]) + assert len(result.rows) == 1 + row = result.rows[0] + assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(100), Decimal(400), Decimal(500)) + assert (row.opening_credit, row.turnover_credit, row.closing_credit) == (ZERO, ZERO, ZERO) + assert row.period == "2024 Q3" + assert row.is_cumulative is False + assert row.entry_ids == (r1.id, r2.id, r3.id) + assert (row.legal_entity_id, row.legal_entity_name, row.legal_entity_inn) == (RB.id, RB.name, RB.inn) + assert (row.account_code, row.account_root, row.counterparty_name) == ("60", "60", "А") + assert result.gaps == () + assert result.documents_total == 3 # noqa: PLR2004 + + +def test_counterparty_only_in_middle_quarter() -> None: + """Сценарий «Контрагент только во втором квартале»: opening/closing — нули, turnover — из Q2.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + only = _row(q2, "Б", row_number=4, td="70", cd="70") + result = _build([q1, q2, q3], [_row(q1), only, _row(q3)]) + row = next(r for r in result.rows if r.counterparty_name == "Б") + assert _sums(row) == (ZERO, ZERO, Decimal(70), ZERO, ZERO, ZERO) + assert row.entry_ids == (only.id,) + assert row.row_number == 4 # noqa: PLR2004 - ключа нет в последнем квартале: номер из последнего, где он есть + + +def test_gap_skips_pair_and_lists_missing_quarters() -> None: + """Сценарий «Пробел»: Q1 и Q3 есть, Q2 нет → строк по 60 нет, пробел с `Q2`; соседний счёт строится.""" + q1, q3 = _doc(quarter=Q1), _doc(quarter=Q3) + cumulative_62 = _doc(account="62", cumulative=True) + result = _build([q1, q3, cumulative_62], [_row(q1), _row(q3), _row(cumulative_62, "В")]) + assert [r.account_code for r in result.rows] == ["62"] + assert result.gaps == (GapItem(RB.id, RB.name, "60", (Q2,)),) + assert result.documents_total == 3 # noqa: PLR2004 + + +def test_gap_of_cumulative_from_earlier_quarter() -> None: + """Review Focus 2 (защитно): нарастающий Q2 при версии Q3 — не источник; пара — пробел Q1–Q3.""" + stale = _doc(account="62", quarter=Q2, cumulative=True) + result = _build([stale], [_row(stale)]) + assert result.rows == () + assert result.gaps == (GapItem(RB.id, RB.name, "62", (Q1, Q2, Q3)),) + + +def test_cumulative_next_to_quarterly_rows_as_is() -> None: + """Сценарий «Нарастающий рядом с квартальными»: строки нарастающего один в один (суммы `None` — как есть).""" + quarterly = [_doc(account="62", quarter=q) for q in (Q1, Q2, Q3)] + cumulative = _doc(account="62", cumulative=True) + source = _row(cumulative, row_number=9, od="1.50", oc=None, td="2", tc=None, cd="3.50", cc=None) + result = _build([*quarterly, cumulative], [*(_row(d, td="999") for d in quarterly), source]) + assert len(result.rows) == 1 + row = result.rows[0] + assert row.is_cumulative is True + assert row.entry_ids == (source.id,) + assert row.row_number == 9 # noqa: PLR2004 + assert _sums(row) == (Decimal("1.50"), None, Decimal(2), None, Decimal("3.50"), None) + assert result.gaps == () + + +def test_cumulative_document_keeps_duplicate_keys_separate() -> None: + """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" + cumulative = _doc(cumulative=True) + first, second = _row(cumulative, row_number=1, td="10"), _row(cumulative, row_number=2, td="20") + result = _build([cumulative], [second, first]) + assert [r.entry_ids for r in result.rows] == [(first.id,), (second.id,)] + + +def test_q1_version_quarterly_document_is_single_quarter_glue() -> None: + q1 = _doc(quarter=Q1) + source = _row(q1, od="5", td="6", cd="11") + result = _build([q1], [source], quarter=Q1) + assert len(result.rows) == 1 + assert result.rows[0].period == "2024 Q1" + assert result.rows[0].is_cumulative is False + assert _sums(result.rows[0]) == (Decimal(5), ZERO, Decimal(6), ZERO, Decimal(11), ZERO) + + +def test_duplicate_key_within_quarter_is_summed() -> None: + """Решение №1: две строки одного ключа в квартале суммируются, `entry_ids` — обе; row_number — минимальный.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + a = _row(q1, row_number=3, od="10", td="1") + b = _row(q1, row_number=8, od="20", td="2") + c = _row(q2, td="4") + d = _row(q3, row_number=7, td="8", cd="100") + e = _row(q3, row_number=5, td="16", cd="200") + result = _build([q1, q2, q3], [b, a, c, d, e]) + assert len(result.rows) == 1 + row = result.rows[0] + assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(30), Decimal(31), Decimal(300)) + assert set(row.entry_ids) == {a.id, b.id, c.id, d.id, e.id} + assert row.entry_ids == (a.id, b.id, c.id, e.id, d.id) # по кварталам, внутри — по номеру строки + assert row.row_number == 5 # noqa: PLR2004 - минимум по последнему кварталу, не по Q1 + + +def test_none_amounts_count_as_zero_when_glued() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + _row(q1, od=None, oc="7", td=None, tc="1", cd=None, cc=None), + _row(q2, td="2", tc=None), + _row(q3, td=None, tc=None, cd="2", cc=None), + ] + row = _build([q1, q2, q3], rows).rows[0] + assert _sums(row) == (ZERO, Decimal(7), Decimal(2), Decimal(1), Decimal(2), ZERO) + assert all(isinstance(value, Decimal) for value in _sums(row)) + + +def test_glue_key_strips_name_and_inn_whitespace() -> None: + """Ключ — (счёт строки, имя.strip(), ИНН без пробелов); «как в файле» — из последнего квартала.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + _row(q1, " ООО Ромашка ", inn="7701 000 001", td="1"), + _row(q2, "ООО Ромашка", inn=" 7701000001", td="2"), + _row(q3, "ООО Ромашка ", inn="7701000001 ", td="4"), + ] + result = _build([q1, q2, q3], rows) + assert len(result.rows) == 1 + assert result.rows[0].turnover_debit == Decimal(7) + assert (result.rows[0].counterparty_name, result.rows[0].counterparty_inn) == ("ООО Ромашка ", "7701000001 ") + + +def test_glue_key_separates_inn_and_subaccount() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [ + *(_row(d, "А", inn="7701000001") for d in (q1, q2, q3)), + *(_row(d, "А", inn="7701000002") for d in (q1, q2, q3)), + *(_row(d, "А", inn="7701000001", account_code="60.02") for d in (q1, q2, q3)), + *(_row(d, "А", inn=None) for d in (q1, q2, q3)), + ] + result = _build([q1, q2, q3], rows) + assert len(result.rows) == 4 # noqa: PLR2004 + assert all(len(r.entry_ids) == 3 for r in result.rows) # noqa: PLR2004 + + +def test_rows_of_unknown_documents_and_non_members_are_ignored() -> None: + q3 = _doc(cumulative=True) + foreign = _doc(CHKF, cumulative=True) # защитно: документ не члена периметра в свод не попадает + orphan = _row(_doc(cumulative=True)) # строка документа, которого нет во входе + result = _build([q3, foreign], [_row(q3), _row(foreign), orphan]) + assert [r.legal_entity_id for r in result.rows] == [RB.id] + assert result.gaps == () + + +def test_empty_input() -> None: + assert _build([], []) == Consolidated(rows=(), gaps=(), documents_total=0) + assert summary(_build([], [])) == (0, 0, 0) + + +# --------------------------------------------------------------------------- контрагент: id, признак, дорезолв + + +def test_counterparty_id_from_last_quarter_with_warning_on_divergence() -> None: + """Решение №8: расхождение `counterparty_id` между кварталами — значение последнего квартала + `warnings`.""" + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + early = _row(q1, counterparty_id=CHKF.id, match_kind=INN) + unmapped = _row(q2) + late = _row(q3, counterparty_id=SCH.id, match_kind=MANUAL) + resolver = _Resolver() + row = _build([q1, q2, q3], [early, unmapped, late], resolver=resolver).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, MANUAL, True) + assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert len(row.warnings) == 1 + assert str(early.id) in row.warnings[0] + assert str(CHKF.id) in row.warnings[0] + assert resolver.calls == [] + + +def test_counterparty_id_from_earlier_quarter_when_last_is_empty() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(q1, counterparty_id=SCH.id, match_kind=NAME), _row(q2), _row(q3)] + row = _build([q1, q2, q3], rows).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored, row.warnings) == (SCH.id, NAME, True, ()) + + +def test_same_counterparty_in_all_quarters_has_no_warning() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(d, counterparty_id=SCH.id, match_kind=PROPAGATED) for d in (q1, q2, q3)] + row = _build([q1, q2, q3], rows).rows[0] + assert (row.counterparty_id, row.match_kind, row.warnings) == (SCH.id, PROPAGATED, ()) + + +def test_counterparty_types_on_rb_fixture_shape() -> None: + """Сценарий «Признаки на фикстурах РБ»: Счастливочная → РБ по ИНН — GSK; РБ → «Пекарня Счастливочная ООО» без + ИНН не сопоставлена — EXTERNAL; РБ → ЧКФ (в ГК, не в ГСК) — GK.""" + sch_60 = _doc(SCH, cumulative=True) + rb_60 = _doc(RB, cumulative=True) + to_rb = _row(sch_60, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN) + to_sch = _row(rb_60, "Пекарня Счастливочная ООО", row_number=1) + to_chkf = _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME) + result = _build([sch_60, rb_60], [to_rb, to_sch, to_chkf]) + by_entry = {r.entry_ids[0]: r for r in result.rows} + assert (by_entry[to_rb.id].counterparty_type, by_entry[to_rb.id].match_kind) == (GSK, INN) + assert by_entry[to_rb.id].match_stored is True + assert by_entry[to_sch.id].counterparty_type == EXTERNAL + assert (by_entry[to_sch.id].counterparty_id, by_entry[to_sch.id].match_kind) == (None, None) + assert by_entry[to_sch.id].match_stored is False + assert by_entry[to_sch.id].counterparty_entity_name is None + assert by_entry[to_chkf.id].counterparty_type == GK + assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name + + +def test_classify_counterparty_type() -> None: + def kind(counterparty_id: uuid.UUID | None) -> CounterpartyType: + return classify(counterparty_id, members=MEMBERS, entities=ENTITIES, group_id=GROUP) + + assert (kind(SCH.id), kind(RB.id), kind(CHKF.id)) == (GSK, GSK, GK) + assert (kind(OUTSIDER.id), kind(LONE.id), kind(None), kind(uuid.uuid4())) == (EXTERNAL,) * 4 + + +def test_resolve_on_read_is_not_stored() -> None: + """Сценарий «Дорезолв на чтение»: пустой `counterparty_id` резолвится на лету, `match_stored = false`, + входная строка не меняется.""" + cumulative = _doc(cumulative=True) + source = _row(cumulative, "Пекарня Счастливочная ООО", inn=" ") + resolver = _Resolver({(" ", "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) + row = _build([cumulative], [source], resolver=resolver).rows[0] + assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, ALIAS, False) + assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name) + assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")] + assert source.counterparty_id is None + assert source.match_kind is None + + +def test_resolve_on_read_for_glued_row_uses_last_quarter_values() -> None: + q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3) + rows = [_row(q1, " ЧКФ", inn="3528 000002"), _row(q2, "ЧКФ", inn="3528000002"), _row(q3, "ЧКФ ", inn="3528000002")] + resolver = _Resolver({("3528000002", "ЧКФ "): Resolution(CHKF.id, INN)}) + result = _build([q1, q2, q3], rows, resolver=resolver) + assert len(result.rows) == 1 + row = result.rows[0] + assert resolver.calls == [("3528000002", "ЧКФ ")] + assert (row.counterparty_id, row.match_kind, row.match_stored, row.counterparty_type) == (CHKF.id, INN, False, GK) + + +def test_rows_are_frozen() -> None: + row = _build([cumulative := _doc(cumulative=True)], [_row(cumulative)]).rows[0] + with pytest.raises(dataclasses.FrozenInstanceError): + row.counterparty_id = SCH.id # type: ignore[misc] + + +# --------------------------------------------------------------------------- порядок + + +def test_rows_order_entity_name_account_row_number_entry_id() -> None: + """Порядок: имя ЮЛ, счёт (натурально: 9 < 60 < 60.2 < 60.10), номер строки (пустой — в конце), первый entry_id.""" + rb_9, rb_60, sch_60 = _doc(RB, "9", cumulative=True), _doc(RB, "60", cumulative=True), _doc(SCH, cumulative=True) + rows = [ + _row(sch_60, "С1", row_number=1), + _row(rb_60, "Р3", row_number=None, account_code="60.10"), + _row(rb_60, "Р2", row_number=2, account_code="60.10"), + _row(rb_60, "Р1", row_number=5, account_code="60.2"), + _row(rb_60, "Р0", row_number=7, account_code="60"), + _row(rb_9, "Р9", row_number=100), + ] + tied = sorted( + [_row(rb_60, "Т1", row_number=3, account_code="60.10"), _row(rb_60, "Т2", row_number=3, account_code="60.10")], + key=lambda r: r.id, + ) + result = _build([rb_9, rb_60, sch_60], [*rows, *reversed(tied)]) + names = [r.counterparty_name for r in result.rows] + assert names == ["Р9", "Р0", "Р1", "Р2", tied[0].counterparty_name, tied[1].counterparty_name, "Р3", "С1"] + + +def test_build_is_deterministic_for_shuffled_input() -> None: + documents = [ + _doc(entity, account, quarter) for entity in (RB, SCH) for account in ("60", "62") for quarter in (Q1, Q2, Q3) + ] + rows = [_row(d, name, row_number=n, td=str(n)) for d in documents for n, name in enumerate(("А", "Б", "В"))] + expected = _build(documents, rows) + shuffled_docs, shuffled_rows = documents[:], rows[:] + random.Random(7).shuffle(shuffled_docs) + random.Random(11).shuffle(shuffled_rows) + assert _build(shuffled_docs, shuffled_rows) == expected + assert len(expected.rows) == 12 # noqa: PLR2004 - 2 ЮЛ × 2 счёта × 3 контрагента + + +def test_gaps_are_ordered_by_entity_name_and_account() -> None: + documents = [_doc(SCH, "62", Q1), _doc(RB, "62", Q1), _doc(RB, "60", Q2)] + result = _build(documents, []) + assert [(g.legal_entity_name, g.account_code, g.missing_quarters) for g in result.gaps] == [ + (RB.name, "60", (Q1, Q3)), + (RB.name, "62", (Q2, Q3)), + (SCH.name, "62", (Q2, Q3)), + ] + + +# --------------------------------------------------------------------------- фильтры и сводка + + +def _filter_fixture() -> tuple[ConsolidatedRow, ...]: + rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True) + rows = [ + _row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена + _row(rb, "Пекарня Счастливочная ООО", row_number=2), # ALIAS дорезолвом + _row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME + _row(sch, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN), # GSK, INN + _row(sch, "Внешний", row_number=2, counterparty_id=OUTSIDER.id, match_kind=MANUAL), # EXTERNAL + ] + resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) + return _build([rb, sch], rows, resolver=resolver).rows + + +def _names(rows: Sequence[ConsolidatedRow]) -> list[str | None]: + return [r.counterparty_name for r in rows] + + +def test_empty_filter_returns_all_rows() -> None: + rows = _filter_fixture() + assert apply_filter(rows, OsvFilter()) == list(rows) + + +@pytest.mark.parametrize( + ("osv_filter", "expected"), + [ + (OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]), + (OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]), + (OsvFilter(account_root="6"), []), + (OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]), + (OsvFilter(counterparty_type=GK), ["ЧКФ"]), + (OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]), + (OsvFilter(unmapped=True), ["ООО Ромашка"]), + (OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + (OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]), + (OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]), + (OsvFilter(q="рОмАш"), ["ООО Ромашка"]), + (OsvFilter(q=" бисквит "), ["Русский бисквит"]), + (OsvFilter(q="7701"), ["ООО Ромашка"]), + (OsvFilter(q="1000001"), []), # ИНН — только префикс + (OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]), + (OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]), + (OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]), + (OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []), + ], +) +def test_apply_filter(osv_filter: OsvFilter, expected: list[str]) -> None: + assert _names(apply_filter(_filter_fixture(), osv_filter)) == expected + + +def test_summary_counts_and_matches_filters() -> None: + """Сценарии «Только несопоставленные» и «Фильтр по способу»: total фильтра = счётчик сводки.""" + rows = _filter_fixture() + result = Consolidated(rows=rows, gaps=(), documents_total=2) + rows_total, unmapped_total, auto_matched_total = summary(result) + assert (rows_total, unmapped_total, auto_matched_total) == (5, 1, 2) + assert len(apply_filter(rows, OsvFilter(unmapped=True))) == unmapped_total + assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_total + assert all(r.counterparty_id is None for r in apply_filter(rows, OsvFilter(unmapped=True))) + + +def test_counterparty_type_is_str_enum() -> None: + assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"] diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py index 7a04477..501af6a 100644 --- a/api/tests/test_domain_consolidation_periods.py +++ b/api/tests/test_domain_consolidation_periods.py @@ -1,12 +1,24 @@ -"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40).""" +"""Период консолидации (Срез 4): отчётная дата (OQ-40) и выбор источников ОСВ на пару (ЮЛ, счёт) — D-2, §2.""" import datetime +import uuid +from dataclasses import dataclass import pytest -from src.domain.consolidation_periods import reporting_date +from src.domain.consolidation_periods import ( + QUARTER_ORDER, + Gap, + Sources, + counts_as_cumulative, + quarters_through, + reporting_date, + select_sources, +) from src.models.enums import PeriodQuarter +Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 + @pytest.mark.parametrize( ("year", "quarter", "expected"), @@ -20,3 +32,86 @@ from src.models.enums import PeriodQuarter ) def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None: assert reporting_date(year, quarter) == expected + + +@dataclass(frozen=True) +class _Doc: + """Документ для `select_sources`: структурно подходит под `DocLike` (как `Document` и `SourceDocument`).""" + + period_quarter: PeriodQuarter | None + is_cumulative: bool | None + id: uuid.UUID = uuid.UUID(int=0) + + +def _doc(quarter: PeriodQuarter | None, cumulative: bool | None = False) -> _Doc: + return _Doc(quarter, cumulative, uuid.uuid4()) + + +def test_quarter_order_and_quarters_through() -> None: + assert QUARTER_ORDER == (Q1, Q2, Q3, Q4) + assert quarters_through(Q1) == (Q1,) + assert quarters_through(Q3) == (Q1, Q2, Q3) + assert quarters_through(Q4) == QUARTER_ORDER + + +def test_counts_as_cumulative_null_is_cumulative() -> None: + """R-1, решение №10: NULL (ББ/ОФР) — нарастающий; только `False` — квартальный.""" + assert counts_as_cumulative(True) is True + assert counts_as_cumulative(None) is True + assert counts_as_cumulative(False) is False + + +def test_cumulative_of_version_quarter_is_single_source() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [cumulative]) == Sources(documents=(cumulative,), cumulative=True) + + +def test_null_cumulative_flag_of_version_quarter_counts_as_cumulative() -> None: + document = _doc(Q3, None) + assert select_sources(Q3, [document]) == Sources(documents=(document,), cumulative=True) + + +def test_quarterly_q1_to_q3_are_sources_in_quarter_order() -> None: + q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q3, q1, q2]) == Sources(documents=(q1, q2, q3), cumulative=False) + + +def test_missing_middle_quarter_is_gap() -> None: + assert select_sources(Q3, [_doc(Q1), _doc(Q3)]) == Gap(missing_quarters=(Q2,)) + + +def test_q1_version_quarterly_q1_is_enough() -> None: + """Для версии Q1 квартальный Q1 равнозначен нарастающему: источник есть, пробела нет.""" + q1 = _doc(Q1) + assert select_sources(Q1, [q1]) == Sources(documents=(q1,), cumulative=False) + + +def test_cumulative_beats_quarterly_of_same_pair() -> None: + """Review Focus 2: квартальные Q1–Q3 и нарастающий Q3 — источник один, нарастающий.""" + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1), _doc(Q2), _doc(Q3), cumulative]) == Sources((cumulative,), cumulative=True) + + +def test_cumulative_beats_quarterly_with_gap() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1), cumulative]) == Sources((cumulative,), cumulative=True) + + +def test_cumulative_of_earlier_quarter_is_ignored() -> None: + """Review Focus 2 (защитно): нарастающий Q2 при версии Q3 не привязывается, но и функция его не берёт.""" + assert select_sources(Q3, [_doc(Q2, True)]) == Gap(missing_quarters=(Q1, Q2, Q3)) + + +def test_cumulative_of_earlier_quarter_does_not_fill_gap_of_quarterly() -> None: + assert select_sources(Q3, [_doc(Q1), _doc(Q2, True), _doc(Q3)]) == Gap(missing_quarters=(Q2,)) + + +def test_quarterly_after_version_and_without_quarter_are_ignored() -> None: + q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q1, q2, q3, _doc(Q4), _doc(None), _doc(None, True)]) == Sources((q1, q2, q3), False) + assert select_sources(Q2, [_doc(Q3)]) == Gap(missing_quarters=(Q1, Q2)) + + +def test_no_documents_is_gap_of_all_quarters() -> None: + assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3)) + assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,)) diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 92068a8..906bc92 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -8,7 +8,9 @@ import pytest from pydantic import ValidationError from src.domain.checklist import CellStatus, EntityFilesState +from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter +from src.schemas import consolidations as schemas from src.schemas.consolidations import ( ChecklistCellOut, ConsolidatedRowOut, @@ -89,3 +91,11 @@ def test_checklist_cell_status_is_the_domain_enum() -> None: assert cell.status is status with pytest.raises(ValidationError): ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] + + +def test_counterparty_type_is_the_domain_enum() -> None: + """Признак контрагента в схеме — `domain.consolidated_osv.CounterpartyType` (одно определение, T2.8).""" + assert schemas.CounterpartyType is DomainCounterpartyType + assert OsvFilters(counterparty_type="GK").counterparty_type is DomainCounterpartyType.GK + with pytest.raises(ValidationError): + OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 06a9a3d..494016f 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -187,9 +187,9 @@ - `consolidated_osv.py`: входные dataclass'ы `SourceRow` (из `OsvEntry`: `id`, `document_id`, `row_number`, `account_code`, `account_root`, `counterparty_name`, `counterparty_inn`, `counterparty_id`, `match_kind`, шесть сумм), `SourceDocument` (`id`, `legal_entity_id`, `osv_account_code`, `period_quarter`, `is_cumulative`), `Entity` (`id`, `name`, `inn`, `company_group_id`); `class CounterpartyType(StrEnum): GSK, GK, EXTERNAL`; `@dataclass(frozen=True) class ConsolidatedRow` (поля D-16 + `warnings: tuple[str, ...]`); `@dataclass(frozen=True) class GapItem(legal_entity_id, legal_entity_name, account_code, missing_quarters)`; `@dataclass(frozen=True) class Consolidated: rows: tuple[ConsolidatedRow, ...]; gaps: tuple[GapItem, ...]; documents_total: int`; `class Resolver(Protocol): def resolve(self, inn, name) -> Resolution | None`; `def build(*, year: int, quarter: PeriodQuarter, group_id: uuid.UUID, members: Mapping[uuid.UUID, Entity], entities: Mapping[uuid.UUID, Entity], documents: Sequence[SourceDocument], rows: Sequence[SourceRow], resolver: Resolver) -> Consolidated`; `@dataclass(frozen=True) class OsvFilter(legal_entity_id, account_root, counterparty_type, unmapped, match_kinds: frozenset, q)`; `def apply_filter(rows, f) -> list[ConsolidatedRow]`; `def summary(c: Consolidated) -> tuple[int rows_total, int unmapped_total, int auto_matched_total]`. - Ключ склейки: `(account_code or "", (counterparty_name or "").strip(), normalize_inn(counterparty_inn or ""))`; `counterparty_id` из последнего квартала (D-3, #8), `warnings` при расхождении; `row_number` = min по последнему кварталу; нули — `Decimal("0")`; суммы `None` считаются нулями при сложении, но строка нарастающего документа отдаётся как есть. -- [ ] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. -- [ ] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. +- [x] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. +- [x] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. +- [x] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 9ee8820..6db1dec 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -234,3 +234,10 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) | | 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая | | 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая | +| 50 | 2026-10-06 | Склейка квартальных строк всегда даёт `Decimal`: сумма `None` и отсутствующая строка — ноль, в том числе в `opening_*` из Q1 и `closing_*` из Qn; «как есть» (с `None`) отдаются только строки нарастающего документа. Версия за Q1 с квартальным Q1 идёт путём склейки (`is_cumulative = false`, дубли ключа суммируются) | пустая ячейка ОСВ означает ноль; одно правило для всех склеенных строк; признак строки отражает тип документа | в склеенной строке пустая ячейка файла показывается как `0.00`, а не пусто | +| 51 | 2026-10-06 | «Как в файле» (`account_code`, `account_root`, `counterparty_name/inn`) и `row_number` склеенной строки — из последнего квартала, где ключ есть (минимальный номер строки в нём, пустой — в конце, затем `id`); если ключа нет в Qn (контрагент только в Q2) — из последнего квартала с ним | спека задаёт `row_number` по последнему кварталу, но не говорит про ключ, которого в нём нет; написание имени/ИНН внутри ключа может отличаться пробелами | порядок такой строки в своде и показанное написание имени | +| 52 | 2026-10-06 | `counterparty_id` склейки — первая сопоставленная строка самого позднего квартала, где сопоставленные есть; `match_kind` и `match_stored = true` — от неё же; в `warnings` (по строке на источник: «Qn: строка сопоставлена с , в своде — ») попадают только строки с ДРУГИМ непустым `counterparty_id`, пустые расхождением не считаются. Дорезолв (`Resolver.resolve(inn, name)`) вызывается только когда сопоставленных нет, по ИНН/имени «как в файле» той же строки; не найден → `match_stored = false`, `match_kind = null`, `EXTERNAL` | D-3 и решение №8; несопоставленная строка рядом с сопоставленной — обычное состояние до автоприменения | текст предупреждений — не контракт, фронт показывает как есть | +| 53 | 2026-10-06 | Порядок счетов в своде и пробелах — натуральный (`account_sort_key`, перенесён из чек-листа в `domain/osv_mapping.py`: `9 < 60 < 60.2 < 60.10`), при равенстве — код строкой; внутри имени ЮЛ — ещё `legal_entity_id` (тёзки не перемешиваются) | одно определение порядка счетов для чек-листа и свода (№48); спека говорит «account_code», текстовая сортировка ставит `60.10` раньше `60.2` | порядок строк в UI/экспорте при необычных кодах | +| 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка | +| 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех | +| 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index b8ab252..4c2e5c1 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -43,7 +43,7 @@ ## 4. Сводная ОСВ -- [ ] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py: +- [x] 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`. Тесты: страница на -- 2.47.2 From a2c2135811a2dbea069abe7a1156528aaa7eea8d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 06:23:24 +0400 Subject: [PATCH 16/27] =?UTF-8?q?feat(consolidation):=20API=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=9E=D0=A1=D0=92=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D1=81=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=B5=D0=BB=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /consolidations/versions/{id}/osv и /osv/summary (CONSOLIDATION_VIEW): ConsolidatedOsvService собирает свод чистой функцией от версии (периметр, привязанные ОСВ, load_osv_rows, один CounterpartyIndex, один SELECT legal_entity) и режет страницу в памяти; чтение любой версии, включая CANCELLED, без блокировок и аудита. OsvFilters отделён от OsvPageParams (T2.7), суммы всегда с двумя знаками; account_sort_key устойчив к «6²»/«①» (T9.a). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/consolidations.py | 11 + .../api/endpoints/consolidations/__init__.py | 3 +- api/src/api/endpoints/consolidations/osv.py | 50 +++ api/src/domain/osv_mapping.py | 7 +- api/src/schemas/consolidations.py | 33 +- api/src/services/consolidated_osv.py | 163 +++++++ api/tests/test_api_consolidated_osv.py | 420 ++++++++++++++++++ api/tests/test_domain_osv_mapping.py | 14 + api/tests/test_openapi_routes.py | 22 + api/tests/test_schemas_consolidations.py | 82 ++-- .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 5 + .../changes/slice-4-consolidation/tasks.md | 2 +- 13 files changed, 778 insertions(+), 40 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/osv.py create mode 100644 api/src/services/consolidated_osv.py create mode 100644 api/tests/test_api_consolidated_osv.py diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 429ca3e..7dcd637 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -9,6 +9,7 @@ from src.api.dependencies.auth import Principal, current_user from src.api.dependencies.db import SessionDep from src.api.dependencies.directories import get_scope from src.domain.policy import VisibilityScope +from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService @@ -49,3 +50,13 @@ def get_status_service( ConsolidationLockServiceDep = Annotated[ConsolidationLockService, Depends(get_lock_service)] ConsolidationStatusServiceDep = Annotated[ConsolidationStatusService, Depends(get_status_service)] + + +def get_consolidated_osv_service( + session: SessionDep, scope: Annotated[VisibilityScope, Depends(get_scope)] +) -> ConsolidatedOsvService: + """Сервис сводной ОСВ версии: только чтение, без аудита и блокировок.""" + return ConsolidatedOsvService(session, scope) + + +ConsolidatedOsvServiceDep = Annotated[ConsolidatedOsvService, Depends(get_consolidated_osv_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index b8c3992..34601ef 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import checklist, locks, members, periods, status, versions +from src.api.endpoints.consolidations import checklist, locks, members, osv, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -11,3 +11,4 @@ router.include_router(locks.router) router.include_router(status.router) router.include_router(members.router) router.include_router(checklist.router) +router.include_router(osv.router) diff --git a/api/src/api/endpoints/consolidations/osv.py b/api/src/api/endpoints/consolidations/osv.py new file mode 100644 index 0000000..22a9034 --- /dev/null +++ b/api/src/api/endpoints/consolidations/osv.py @@ -0,0 +1,50 @@ +"""Сводная ОСВ версии (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16): страница с фильтрами и +сводка с пробелами. Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у несуществующей; +читается версия в любом статусе.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep +from src.schemas.common import ErrorDetail, Page +from src.schemas.consolidations import ConsolidatedRowOut, OsvQuery, OsvSummaryOut + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}/osv", + response_model=Page[ConsolidatedRowOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Сводная ОСВ версии", +) +async def list_osv( + version_id: uuid.UUID, + service: ConsolidatedOsvServiceDep, + query: Annotated[OsvQuery, Query()], +) -> Page[ConsolidatedRowOut]: + """Строки свода в порядке: имя ЮЛ, счёт, номер строки, первый `entry_id`. Фильтры `legal_entity_id`, + `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`; `limit` ≤ 2000 (по умолчанию 500). + Суммы — строки с двумя знаками.""" + return await service.page(version_id, query, query) + + +@router.get( + "/{version_id}/osv/summary", + response_model=OsvSummaryOut, + dependencies=VIEW, + responses=NOT_FOUND, + summary="Сводка по своду версии", +) +async def get_osv_summary(version_id: uuid.UUID, service: ConsolidatedOsvServiceDep) -> OsvSummaryOut: + """Число строк, несопоставленных и автосопоставленных, привязанных ОСВ-документов и пробелы по кварталам.""" + return await service.summary(version_id) diff --git a/api/src/domain/osv_mapping.py b/api/src/domain/osv_mapping.py index 7f7e030..71b2597 100644 --- a/api/src/domain/osv_mapping.py +++ b/api/src/domain/osv_mapping.py @@ -130,9 +130,12 @@ def account_root(code: str) -> str | None: def account_sort_key(code: str) -> tuple[tuple[int, int, str], ...]: """Натуральный ключ сортировки кода счёта: числовые сегменты — по значению (`01 < 9 < 58 < 58.03 < 60 < 60.2 < - 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48).""" + 60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48). + + «Числовой» — только ASCII-цифры (`[0-9]+`): `str.isdigit()` истинно и для `"6²"`/`"①"`, а `int()` на них падает + (код счёта приходит из файла; ошибка роняла бы всю страницу свода). Прочие сегменты — по тексту, после числовых.""" parts = (part for part in _ACCOUNT_SPLIT_RE.split(code) if part) - return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts) + return tuple((0, int(part), "") if _ROOT_SEGMENT_RE.fullmatch(part) else (1, 0, part) for part in parts) def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 79a6396..036b36b 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -2,14 +2,15 @@ Суммы — `Decimal` (в JSON — строка, без `float`). `ConsolidatedRowOut` и `OsvSummaryOut` — сводная ОСВ версии (чистая функция, в БД не хранится; DECISION-5). `OsvFilters` — query-модель фильтров `GET …/osv` -(`Annotated[OsvFilters, Query()]`); `limit` ≤ 2000 по умолчанию 500, как у строк документа. +(`Annotated[OsvFilters, Query()]`), `OsvPageParams` — пагинация (`limit` ≤ 2000 по умолчанию 500, как у строк +документа). Суммы свода — всегда с двумя знаками (`"0.00"`, `"30.00"`): деньги `numeric(18,2)`. """ import datetime import uuid from decimal import Decimal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from src.domain.checklist import CellStatus from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8) @@ -27,6 +28,7 @@ YEAR_MAX = 2100 DEFAULT_OSV_LIMIT = 500 MAX_OSV_LIMIT = 2000 MAX_TAKEOVER_REASON = 1000 +CENT = Decimal("0.01") class RefOut(BaseModel): @@ -196,6 +198,20 @@ class ConsolidatedRowOut(BaseModel): closing_credit: Decimal | None warnings: list[str] + @field_validator( + "opening_debit", + "opening_credit", + "turnover_debit", + "turnover_credit", + "closing_debit", + "closing_credit", + ) + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """`numeric(18,2)`: сумма склейки (`Decimal("0")` для отсутствующей строки, сумма кварталов) в JSON — всегда + `"0.00"`/`"30.00"`; значение не меняется (знаков после запятой в данных больше двух не бывает).""" + return None if value is None else value.quantize(CENT) + class GapOut(BaseModel): """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за перечисленные кварталы (строки по нему не строятся).""" @@ -217,7 +233,7 @@ class OsvSummaryOut(BaseModel): class OsvFilters(BaseModel): - """Фильтры и пагинация `GET …/osv` из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый.""" + """Фильтры `GET …/osv` (и экспорта) из query: `Annotated[OsvFilters, Query()]`; `match_kind` повторяемый.""" legal_entity_id: uuid.UUID | None = None account_root: str | None = None @@ -225,10 +241,21 @@ class OsvFilters(BaseModel): unmapped: bool | None = None match_kind: list[CounterpartyMatchKind] | None = None q: str | None = None + + +class OsvPageParams(BaseModel): + """Пагинация `GET …/osv` из query: `Annotated[OsvPageParams, Query()]` (как `EntriesPageParams`).""" + limit: int = Field(DEFAULT_OSV_LIMIT, ge=1, le=MAX_OSV_LIMIT) offset: int = Field(0, ge=0) +class OsvQuery(OsvFilters, OsvPageParams): + """Query-параметры `GET …/osv` целиком: FastAPI принимает ровно одну query-модель на маршрут, поэтому фильтры и + пагинация сливаются; сервису модель передаётся и как `OsvFilters`, и как `OsvPageParams` (экспорт берёт только + `OsvFilters`).""" + + class CandidateOut(BaseModel): """Кандидат для ручного сопоставления контрагента (`GET …/counterparty-candidates`).""" diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py new file mode 100644 index 0000000..45aec0d --- /dev/null +++ b/api/src/services/consolidated_osv.py @@ -0,0 +1,163 @@ +"""Сводная ОСВ версии: страница и сводка (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16). + +Свод — чистая функция версии (DECISION-5), в БД не хранится: каждый запрос заново собирает его из периметра, +привязанных ОСВ-документов и их строк (`domain.consolidated_osv.build`) и отдаёт срез. Сервис только читает: без +блокировок и аудита, версия в любом статусе (включая `CANCELLED`) читается; видимость — через ГК консолидации +(чужая и несуществующая версии — один 404 `VERSION_NOT_FOUND`). + +Запросов к БД — константа, не зависящая от числа строк и контрагентов (без N+1): `get_version`, периметр, +документы версии, строки, два запроса индекса контрагентов (`CounterpartyIndex.load`) и один `SELECT legal_entity` по +собранным идентификаторам — контрагенты строк и кандидаты дорезолва. Дорезолв на лету кормит `build` тем же +`CounterpartyIndex`, поэтому идентификаторы кандидатов собираются заранее тем же `resolve` по словарям в памяти. +""" + +import uuid +from collections.abc import Collection, Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import NotFoundError +from src.domain import consolidated_osv as domain +from src.domain.policy import VisibilityScope +from src.models.consolidation import Consolidation, ConsolidationVersion +from src.models.directories import LegalEntity +from src.models.enums import DocumentType +from src.models.files import Document +from src.models.osv import OsvEntry +from src.repositories import consolidations as repo +from src.repositories.counterparties import CounterpartyIndex +from src.schemas.common import Page +from src.schemas.consolidations import ConsolidatedRowOut, GapOut, OsvFilters, OsvPageParams, OsvSummaryOut +from src.services.consolidation_guard import VERSION_NOT_FOUND + +__all__ = ["ConsolidatedOsvService", "domain_filter", "row_out"] + + +def row_out(row: domain.ConsolidatedRow) -> ConsolidatedRowOut: + """Строка свода → схема API: кортежи домена (`entry_ids`, `warnings`) — списки, суммы — `Decimal` (JSON-строка).""" + return ConsolidatedRowOut.model_validate(row, from_attributes=True) + + +def domain_filter(f: OsvFilters) -> domain.OsvFilter: + """Query-модель фильтров → доменный `OsvFilter` (повторяемый `match_kind` → множество).""" + return domain.OsvFilter( + legal_entity_id=f.legal_entity_id, + account_root=f.account_root, + counterparty_type=f.counterparty_type, + unmapped=f.unmapped, + match_kinds=frozenset(f.match_kind or ()), + q=f.q, + ) + + +def _entity(entity: LegalEntity) -> domain.Entity: + return domain.Entity(id=entity.id, name=entity.name, inn=entity.inn, company_group_id=entity.company_group_id) + + +def _source_document(document: Document) -> domain.SourceDocument: + return domain.SourceDocument( + id=document.id, + legal_entity_id=document.legal_entity_id, + osv_account_code=document.osv_account_code, + period_quarter=document.period_quarter, + is_cumulative=document.is_cumulative, + ) + + +def _source_row(entry: OsvEntry) -> domain.SourceRow: + return domain.SourceRow( + id=entry.id, + document_id=entry.document_id, + row_number=entry.row_number, + account_code=entry.account_code, + account_root=entry.account_root, + counterparty_name=entry.counterparty_name, + counterparty_inn=entry.counterparty_inn, + counterparty_id=entry.counterparty_id, + match_kind=entry.counterparty_match_kind, + opening_debit=entry.opening_debit, + opening_credit=entry.opening_credit, + turnover_debit=entry.turnover_debit, + turnover_credit=entry.turnover_credit, + closing_debit=entry.closing_debit, + closing_credit=entry.closing_credit, + ) + + +def _candidate_ids(rows: Sequence[domain.SourceRow], index: CounterpartyIndex) -> set[uuid.UUID]: + """Идентификаторы контрагентов, имена которых понадобятся `build`: хранимые `counterparty_id` и результат дорезолва + для строк без него (`resolve` — по словарям индекса, без обращений к БД; ключ склейки включает имя и ИНН, поэтому + результат для представителя группы равен результату для любой её строки).""" + ids: set[uuid.UUID] = set() + for row in rows: + if row.counterparty_id is not None: + ids.add(row.counterparty_id) + elif (resolution := index.resolve(row.counterparty_inn, row.counterparty_name)) is not None: + ids.add(resolution.entity_id) + return ids + + +async def _entities(session: AsyncSession, ids: Collection[uuid.UUID]) -> dict[uuid.UUID, domain.Entity]: + """ЮЛ справочника по `ids` одним запросом, без policy (контрагент может быть из любого филиала).""" + if not ids: + return {} + found = await session.execute(select(LegalEntity).where(LegalEntity.id.in_(ids))) + return {entity.id: _entity(entity) for entity in found.scalars()} + + +class ConsolidatedOsvService: + """Чтение сводной ОСВ версии: страница с фильтрами и сводка с пробелами (без блокировок и аудита).""" + + def __init__(self, session: AsyncSession, scope: VisibilityScope) -> None: + self._session = session + self._scope = scope + + async def _build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: + """Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)} + documents = [ + _source_document(document) + for document in await repo.bound_documents(self._session, version.id) + if document.document_type == DocumentType.OSV + ] + # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает + rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] + index = await CounterpartyIndex.load(self._session) + entities = {**await _entities(self._session, _candidate_ids(rows, index) - members.keys()), **members} + consolidated = domain.build( + year=consolidation.year, + quarter=consolidation.quarter, + group_id=consolidation.company_group_id, + members=members, + entities=entities, + documents=documents, + rows=rows, + resolver=index, + ) + return consolidated, version, consolidation + + async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: + """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" + consolidated, _, _ = await self._build(version_id) + rows = domain.apply_filter(consolidated.rows, domain_filter(f)) + window = rows[page.offset : page.offset + page.limit] + return Page[ConsolidatedRowOut]( + items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset + ) + + async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: + """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" + consolidated, _, _ = await self._build(version_id) + rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated) + return OsvSummaryOut( + rows_total=rows_total, + unmapped_total=unmapped_total, + auto_matched_total=auto_matched_total, + documents_total=consolidated.documents_total, + gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps], + ) diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py new file mode 100644 index 0000000..afd7fee --- /dev/null +++ b/api/tests/test_api_consolidated_osv.py @@ -0,0 +1,420 @@ +"""API сводной ОСВ версии (spec consolidated-osv «Страница свода и сводка»; design D-1, D-16): страница, фильтры, +сводка с пробелами, суммы-строки, права и видимость, производительность без N+1.""" + +import time +import uuid +from decimal import Decimal +from typing import Any + +import pytest +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.aliases import normalize_alias +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, RegionalOffice +from src.models.enums import ConsolidationStatus, CounterpartyMatchKind, PeriodQuarter +from src.models.files import Document +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +KA_RF = "ka_rf@armka.local" +CURATOR = "curator_go@armka.local" +FOREIGN = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +D = Decimal +ZERO = D("0.00") +ROWS_TOTAL, UNMAPPED_TOTAL, AUTO_MATCHED_TOTAL, DOCUMENTS_TOTAL = 8, 2, 2, 7 +PERF_MAX_SECONDS = 5.0 +PERF_MAX_SELECTS = 10 + + +class World: + """Версия РБ Q3 2024: периметр {А, Б}; вне периметра — ЧКФ (та же ГК), «Внешний» (имя), Ф (алиас).""" + + version: ConsolidationVersion + a: LegalEntity + b: LegalEntity + chkf: LegalEntity + external: LegalEntity + aliased: LegalEntity + + +def _quarter_rows(world: World, quarter: PeriodQuarter) -> list[dict[str, Any]]: + """Строки ОСВ 60 «А»: opening/turnover/closing по кварталам 100/10/110, 110/10/120, 120/10/130.""" + step = {Q1: 0, Q2: 10, Q3: 20}[quarter] + amounts = { + "opening_debit": D(100 + step), + "turnover_debit": D(10), + "closing_debit": D(110 + step), + } + return [ + { + "name": "ООО Б", + "inn": world.b.inn, + "counterparty_id": world.b.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, + **amounts, + }, + { + "name": "ООО ЧКФ", + "counterparty_id": world.chkf.id, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL, + **amounts, + }, + {"name": "Неизвестный контрагент", **amounts}, + {"name": "ООО Внешний", **amounts}, + {"name": "Пекарня Ф", **amounts}, + ] + + +async def _bind(session: AsyncSession, version: ConsolidationVersion, *documents: Any) -> None: + for document in documents: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + + +async def _world(session: AsyncSession, *, status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION) -> World: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group: CompanyGroup = await make_company_group(session, name="ГК Русский бисквит", office=office) + world = World() + world.a = await make_legal_entity(session, name="ООО А", group=group) + world.b = await make_legal_entity(session, name="ООО Б", group=group) + world.chkf = await make_legal_entity(session, name="ООО ЧКФ", group=group) + world.external = await make_legal_entity(session, name="ООО Внешний") + world.aliased = await make_legal_entity(session, name="Фирма Эф") + session.add( + LegalEntityAlias( + legal_entity_id=world.aliased.id, alias="Пекарня Ф", normalized_alias=normalize_alias("Пекарня Ф") + ) + ) + user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, world.version = await make_consolidation( + session, group=group, year=YEAR, quarter=Q3, members=[world.a, world.b], initiator=user, status=status + ) + documents = [] + for quarter in (Q1, Q2, Q3): + documents.append( + await make_confirmed_osv( + session, + entity=world.a, + account="60", + year=YEAR, + quarter=quarter, + is_cumulative=False, + rows=_quarter_rows(world, quarter), + ) + ) + documents.append( + await make_confirmed_osv( + session, + entity=world.b, + account="60", + year=YEAR, + quarter=quarter, + is_cumulative=False, + rows=[ + { + "name": "ООО А", + "counterparty_id": world.a.id, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL, + "turnover_credit": D("5.00"), + }, + {"name": "Неизвестный Б", "turnover_credit": D("1.00")}, + ], + ) + ) + documents.append( + await make_confirmed_osv( + session, + entity=world.a, + account="62", + year=YEAR, + quarter=Q3, + is_cumulative=True, + rows=[ + { + "name": "ООО Б", + "inn": world.b.inn, + "counterparty_id": world.b.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, + "closing_credit": D("42.50"), + } + ], + ) + ) + await _bind(session, world.version, *documents) + return world + + +def _url(world: World, suffix: str = "osv") -> str: + return f"/consolidations/versions/{world.version.id}/{suffix}" + + +async def _page(client: Any, world: World, query: str = "") -> dict[str, Any]: + response = await client.get(f"{_url(world)}?{query}") + assert response.status_code == HTTP_OK, response.text + body: dict[str, Any] = response.json() + return body + + +def _row(body: dict[str, Any], entity: str, account: str, name: str) -> dict[str, Any]: + return next( + row + for row in body["items"] + if (row["legal_entity_name"], row["account_code"], row["counterparty_name"]) == (entity, account, name) + ) + + +@pytest.mark.integration +async def test_page_rows_order_total_and_money_strings(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world) + + assert (body["total"], body["limit"], body["offset"]) == (ROWS_TOTAL, 500, 0) + assert [(r["legal_entity_name"], r["account_code"], r["row_number"]) for r in body["items"]] == [ + ("ООО А", "60", 1), ("ООО А", "60", 2), ("ООО А", "60", 3), ("ООО А", "60", 4), ("ООО А", "60", 5), + ("ООО А", "62", 1), ("ООО Б", "60", 1), ("ООО Б", "60", 2), + ] # fmt: skip + row = _row(body, "ООО А", "60", "ООО Б") + assert row["period"] == "2024 Q3" and row["is_cumulative"] is False + assert (row["opening_debit"], row["turnover_debit"], row["closing_debit"]) == ("100.00", "30.00", "130.00") + assert (row["opening_credit"], row["turnover_credit"], row["closing_credit"]) == ("0.00", "0.00", "0.00") + assert len(row["entry_ids"]) == 3 # noqa: PLR2004 + assert all(isinstance(entry_id, str) for entry_id in row["entry_ids"]) + assert row["warnings"] == [] + assert row["legal_entity_id"] == str(world.a.id) and row["legal_entity_inn"] == world.a.inn + assert row["account_root"] == "60" and row["counterparty_inn"] == world.b.inn + assert (row["counterparty_id"], row["counterparty_entity_name"]) == (str(world.b.id), "ООО Б") + assert (row["counterparty_type"], row["match_kind"], row["match_stored"]) == ("GSK", "INN", True) + cumulative = _row(body, "ООО А", "62", "ООО Б") + assert cumulative["is_cumulative"] is True and cumulative["closing_credit"] == "42.50" + assert len(cumulative["entry_ids"]) == 1 + + +@pytest.mark.integration +async def test_counterparty_types_and_read_time_resolution( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world) + + chkf = _row(body, "ООО А", "60", "ООО ЧКФ") + assert (chkf["counterparty_type"], chkf["match_kind"], chkf["match_stored"]) == ("GK", "MANUAL", True) + unknown = _row(body, "ООО А", "60", "Неизвестный контрагент") + assert (unknown["counterparty_id"], unknown["counterparty_type"], unknown["match_kind"]) == (None, "EXTERNAL", None) + assert unknown["match_stored"] is False and unknown["counterparty_entity_name"] is None + by_name = _row(body, "ООО А", "60", "ООО Внешний") + assert (by_name["counterparty_id"], by_name["match_kind"], by_name["match_stored"]) == ( + str(world.external.id), "NAME", False) # fmt: skip + assert by_name["counterparty_type"] == "EXTERNAL" + by_alias = _row(body, "ООО А", "60", "Пекарня Ф") + assert (by_alias["counterparty_id"], by_alias["match_kind"], by_alias["match_stored"]) == ( + str(world.aliased.id), "ALIAS", False) # fmt: skip + assert by_alias["counterparty_entity_name"] == "Фирма Эф" + assert _row(body, "ООО Б", "60", "ООО А")["counterparty_type"] == "GSK" + + +@pytest.mark.integration +async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + summary = (await client.get(_url(world, "osv/summary"))).json() + + assert summary == { + "rows_total": ROWS_TOTAL, "unmapped_total": UNMAPPED_TOTAL, "auto_matched_total": AUTO_MATCHED_TOTAL, + "documents_total": DOCUMENTS_TOTAL, "gaps": [], + } # fmt: skip + unmapped = await _page(client, world, "unmapped=true") + assert unmapped["total"] == summary["unmapped_total"] + assert len(unmapped["items"]) == UNMAPPED_TOTAL + assert all(row["counterparty_id"] is None for row in unmapped["items"]) + mapped = await _page(client, world, "unmapped=false") + assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL + + +@pytest.mark.integration +async def test_filter_by_match_kind_repeated_param(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world, "match_kind=NAME&match_kind=ALIAS") + summary = (await client.get(_url(world, "osv/summary"))).json() + + assert body["total"] == summary["auto_matched_total"] == AUTO_MATCHED_TOTAL + assert {row["match_kind"] for row in body["items"]} == {"NAME", "ALIAS"} + assert (await _page(client, world, "match_kind=MANUAL"))["total"] == 2 # noqa: PLR2004 - ЧКФ и «ООО А» у Б + assert (await _page(client, world, "match_kind=INN"))["total"] == 2 # noqa: PLR2004 - 60 и 62 + + +@pytest.mark.integration +async def test_filters_q_entity_account_root_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + by_name = await _page(client, world, "q=%D0%B2%D0%BD%D0%B5%D1%88") # «внеш» — регистр не важен + assert [row["counterparty_name"] for row in by_name["items"]] == ["ООО Внешний"] + by_inn = await _page(client, world, f"q={world.b.inn[:6]}") # префикс ИНН контрагента + assert {row["counterparty_name"] for row in by_inn["items"]} == {"ООО Б"} and by_inn["total"] == 2 # noqa: PLR2004 + assert (await _page(client, world, f"legal_entity_id={world.b.id}"))["total"] == 2 # noqa: PLR2004 + assert (await _page(client, world, "account_root=62"))["total"] == 1 + assert (await _page(client, world, "counterparty_type=GK"))["total"] == 1 + assert (await _page(client, world, "counterparty_type=GSK"))["total"] == 3 # noqa: PLR2004 + both = await _page(client, world, f"legal_entity_id={world.a.id}&account_root=60&counterparty_type=EXTERNAL") + assert both["total"] == 3 # noqa: PLR2004 - неизвестный, «Внешний», «Пекарня Ф» + + +@pytest.mark.integration +async def test_pagination_window_and_limit_bounds(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world, "limit=2&offset=4") + + assert (body["total"], body["limit"], body["offset"], len(body["items"])) == (ROWS_TOTAL, 2, 4, 2) + assert [row["row_number"] for row in body["items"]] == [5, 1] # пятая строка 60 «А», затем 62 «А» + assert (await _page(client, world, "limit=2000"))["limit"] == 2000 # noqa: PLR2004 + assert (await _page(client, world, f"offset={ROWS_TOTAL}"))["items"] == [] + for bad in ("limit=2001", "limit=0", "offset=-1", "counterparty_type=OTHER", "match_kind=BOGUS"): + assert (await client.get(f"{_url(world)}?{bad}")).status_code == HTTP_UNPROCESSABLE, bad + + +@pytest.mark.integration +async def test_gap_is_reported_and_has_no_rows(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + # у «Б» отвязываем ОСВ 60 за Q2: остаются Q1 и Q3 → пробел + link = ( + await seeded.execute( + select(ConsolidationDocument) + .join(Document, Document.id == ConsolidationDocument.document_id) + .where( + ConsolidationDocument.consolidation_version_id == world.version.id, + Document.legal_entity_id == world.b.id, + Document.period_quarter == Q2, + ) + ) + ).scalar_one() + await seeded.delete(link) + await seeded.flush() + client = await auth_client(KA_RF) + + summary = (await client.get(_url(world, "osv/summary"))).json() + body = await _page(client, world, f"legal_entity_id={world.b.id}") + + assert summary["gaps"] == [ + {"legal_entity_id": str(world.b.id), "legal_entity_name": "ООО Б", "account_code": "60", + "missing_quarters": ["Q2"]} + ] # fmt: skip + assert summary["documents_total"] == DOCUMENTS_TOTAL - 1 + assert body["total"] == 0 + assert summary["rows_total"] == ROWS_TOTAL - 2 + + +@pytest.mark.integration +async def test_cancelled_version_is_readable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + assert (await _page(client, world))["total"] == ROWS_TOTAL + assert (await client.get(_url(world, "osv/summary"))).status_code == HTTP_OK + + +@pytest.mark.integration +async def test_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + curator = await auth_client(CURATOR) + foreign = await auth_client(FOREIGN) + admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет + + assert (await curator.get(_url(world))).status_code == HTTP_OK + assert (await curator.get(_url(world, "osv/summary"))).status_code == HTTP_OK + for suffix in ("osv", "osv/summary"): + hidden = await foreign.get(_url(world, suffix)) + unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/{suffix}") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (await admin.get(_url(world, suffix))).status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_read_is_side_effect_free(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Дорезолв не пишется: `osv_entry.counterparty_id` строки «ООО Внешний» остаётся пустым.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + + await _page(client, world) + + resolved = ( + await seeded.execute( + select(OsvEntry.counterparty_id).where(OsvEntry.counterparty_name.in_(["ООО Внешний", "Пекарня Ф"])) + ) + ).scalars() + assert set(resolved) == {None} + + +@pytest.mark.integration +async def test_large_version_is_fast_without_n_plus_one(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Свод из 5 400 исходных строк (2 ЮЛ × 3 счёта × 3 квартала × 300): ответ быстро, SELECT-ов — константа.""" + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(seeded, name="ГК Большая", office=office) + members = [await make_legal_entity(seeded, name=f"ООО Большая {n}", group=group) for n in range(2)] + counterparties = [await make_legal_entity(seeded, name=f"Контрагент {n:03d}", group=group) for n in range(50)] + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation( + seeded, group=group, year=YEAR, quarter=Q3, members=members, initiator=user, + status=ConsolidationStatus.DATA_CORRECTION, + ) # fmt: skip + documents = [] + for entity in members: + for account in ("60", "62", "76"): + for quarter in (Q1, Q2, Q3): + rows = [ + { + "name": f"Контрагент {n:03d}" if n % 2 else f"Неизвестный {n}", + "counterparty_id": counterparties[n % 50].id if n % 3 == 0 else None, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL if n % 3 == 0 else None, + "opening_debit": D("100.00"), + "turnover_debit": D("10.00"), + "closing_debit": D("110.00"), + } + for n in range(300) + ] + documents.append( + await make_confirmed_osv( + seeded, entity=entity, account=account, year=YEAR, quarter=quarter, is_cumulative=False, + rows=rows, + ) + ) # fmt: skip + await _bind(seeded, version, *documents) + client = await auth_client(KA_RF) + statements: list[str] = [] + connection = (await seeded.connection()).sync_connection + listener = lambda _c, _cur, statement, *_rest: statements.append(statement) # noqa: E731 + event.listen(connection, "before_cursor_execute", listener) + try: + started = time.perf_counter() + response = await client.get(f"/consolidations/versions/{version.id}/osv?limit=2000") + elapsed = time.perf_counter() - started + finally: + event.remove(connection, "before_cursor_execute", listener) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert body["total"] == 2 * 3 * 300 and len(body["items"]) == 1800 # noqa: PLR2004 - 5 400 исходных строк → 1 800 + selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")] + print(f"\nPERF osv: {elapsed:.3f}s, SELECT={len(selects)}, statements={len(statements)}") # noqa: T201 + assert elapsed < PERF_MAX_SECONDS + assert len(selects) <= PERF_MAX_SELECTS, selects diff --git a/api/tests/test_domain_osv_mapping.py b/api/tests/test_domain_osv_mapping.py index 7b5cb6a..bdfd687 100644 --- a/api/tests/test_domain_osv_mapping.py +++ b/api/tests/test_domain_osv_mapping.py @@ -18,6 +18,7 @@ from src.domain.osv_mapping import ( DocumentDraft, EntryDraft, account_root, + account_sort_key, build_entries, counterparty_check, document_fields, @@ -305,3 +306,16 @@ def test_document_status( ) def test_opening_matches(entry: EntryDraft, q1_opening: tuple[Decimal | None, Decimal | None], expected: bool) -> None: assert opening_matches(entry, q1_opening) is expected + + +def test_account_sort_key_is_natural_and_total() -> None: + codes = ["60.10", "60.2", "60", "58.03", "58", "9", "01"] + assert sorted(codes, key=account_sort_key) == ["01", "9", "58", "58.03", "60", "60.2", "60.10"] + + +@pytest.mark.parametrize("code", ["6²", "①", "60.①", "٦٠", "6²⁰.1"]) +def test_account_sort_key_survives_non_ascii_digits(code: str) -> None: + """`"6²".isdigit()` истинно, но `int("6²")` падает: код счёта из файла не должен ронять страницу свода (T9.a).""" + key = account_sort_key(code) + assert key == account_sort_key(code) # детерминирован + assert sorted(["A", code, "60"], key=account_sort_key)[0] == "60" # ASCII-числа — раньше нечисловых сегментов diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index e1a78a0..0a4073f 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -59,6 +59,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/status": {"post"}, "/api/v1/consolidations/versions/{version_id}/members": {"put"}, "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -135,6 +137,26 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"] assert {"200", "404"} <= set(checklist) assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut") + osv = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv"]["get"] + assert {"200", "404", "422"} <= set(osv["responses"]) + assert osv["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith( + "/Page_ConsolidatedRowOut_" + ) + params = {parameter["name"]: parameter for parameter in osv["parameters"]} + assert { + "legal_entity_id", + "account_root", + "counterparty_type", + "unmapped", + "match_kind", + "q", + "limit", + "offset", + } <= set(params) + assert params["match_kind"]["schema"]["anyOf"][0]["type"] == "array" # повторяемый параметр + summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"] + assert {"200", "404"} <= set(summary) + assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut") def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 906bc92..c74a3e5 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -3,6 +3,7 @@ import dataclasses import uuid from decimal import Decimal +from typing import Any import pytest from pydantic import ValidationError @@ -17,6 +18,7 @@ from src.schemas.consolidations import ( ConsolidationCreate, MapCounterpartyIn, OsvFilters, + OsvPageParams, StatusIn, ) @@ -31,12 +33,20 @@ def test_consolidation_create_year_bounds() -> None: ConsolidationCreate(year=year, **ids) -def test_osv_filters_limit_bounds_and_repeated_match_kind() -> None: - assert OsvFilters().limit == 500 # noqa: PLR2004 - assert OsvFilters(limit=MAX_LIMIT).limit == MAX_LIMIT - for bad in ({"limit": MAX_LIMIT + 1}, {"limit": 0}, {"offset": -1}, {"counterparty_type": "OTHER"}): +def test_osv_page_params_limit_bounds() -> None: + assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004 + assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT + for bad in ({"limit": MAX_LIMIT + 1}, {"limit": 0}, {"offset": -1}): with pytest.raises(ValidationError): - OsvFilters(**bad) + OsvPageParams(**bad) + + +def test_osv_filters_repeated_match_kind_and_no_pagination() -> None: + """Фильтры — отдельная модель от пагинации (T2.7): `limit`/`offset` в ней нет.""" + assert "limit" not in OsvFilters.model_fields + assert "offset" not in OsvFilters.model_fields + with pytest.raises(ValidationError): + OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type] assert OsvFilters(match_kind=["NAME", "ALIAS"]).match_kind == [ CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS, @@ -48,36 +58,48 @@ def test_defaults_of_input_schemas() -> None: assert StatusIn(to_status="CANCELLED").comment is None +def _row(**amounts: Decimal | None) -> ConsolidatedRowOut: + fields: dict[str, Any] = { + "entry_ids": [uuid.uuid4()], + "legal_entity_id": uuid.uuid4(), + "legal_entity_name": "ООО", + "legal_entity_inn": "7701000001", + "account_code": "60", + "account_root": "60", + "period": "2024 Q3", + "is_cumulative": False, + "row_number": 1, + "counterparty_name": "А", + "counterparty_inn": None, + "counterparty_id": None, + "counterparty_entity_name": None, + "counterparty_type": "EXTERNAL", + "match_kind": None, + "match_stored": False, + "opening_debit": None, + "opening_credit": None, + "turnover_debit": None, + "turnover_credit": None, + "closing_debit": None, + "closing_credit": None, + "warnings": [], + } + return ConsolidatedRowOut(**{**fields, **amounts}) + + def test_consolidated_row_serializes_money_as_strings() -> None: - row = ConsolidatedRowOut( - entry_ids=[uuid.uuid4()], - legal_entity_id=uuid.uuid4(), - legal_entity_name="ООО", - legal_entity_inn="7701000001", - account_code="60", - account_root="60", - period="2024 Q3", - is_cumulative=False, - row_number=1, - counterparty_name="А", - counterparty_inn=None, - counterparty_id=None, - counterparty_entity_name=None, - counterparty_type="EXTERNAL", - match_kind=None, - match_stored=False, - opening_debit=Decimal("100.50"), - opening_credit=None, - turnover_debit=Decimal("0.00"), - turnover_credit=None, - closing_debit=None, - closing_credit=None, - warnings=[], - ) + row = _row(opening_debit=Decimal("100.50"), turnover_debit=Decimal("0.00")) dumped = row.model_dump(mode="json") assert (dumped["opening_debit"], dumped["opening_credit"], dumped["turnover_debit"]) == ("100.50", None, "0.00") +def test_consolidated_row_money_has_two_decimals() -> None: + """Суммы свода — всегда с двумя знаками (`"0.00"`, не `"0"`): ноль склейки и сумма Decimal не меняют вид.""" + row = _row(opening_debit=Decimal("0"), turnover_debit=Decimal("30.00"), closing_debit=Decimal("7")) + dumped = row.model_dump(mode="json") + assert (dumped["opening_debit"], dumped["turnover_debit"], dumped["closing_debit"]) == ("0.00", "30.00", "7.00") + + def test_entity_files_state_is_frozen() -> None: state = EntityFilesState(has_in_progress=True, last_file_status=FileStatus.ERROR) with pytest.raises(dataclasses.FrozenInstanceError): diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 494016f..a92e51e 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -202,9 +202,9 @@ - `ConsolidatedOsvService(session, scope)`: `async def _build(version_id) -> tuple[Consolidated, ConsolidationVersion, Consolidation]` (`get_version` → `members` → `bound_documents` (ОСВ) → `load_osv_rows` → `CounterpartyIndex.load` → `entities` для `counterparty_id` и кандидатов дорезолва (один `SELECT legal_entity` по собранным id) → `build`); `page(version_id, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]`; `summary(version_id) -> OsvSummaryOut`. - `OsvPageParams`: `limit` ≤ 2000, по умолчанию 500 (как `EntriesPageParams`). Маршруты: `GET …/osv`, `GET …/osv/summary` (`CONSOLIDATION_VIEW`). -- [ ] **Step 1: Тесты.** Integration на `make_confirmed_osv` ×3 квартала ×2 ЮЛ: страница, `total`, сценарии «Только несопоставленные», «Фильтр по способу», `q` по имени/ИНН, `legal_entity_id`, `account_root`, чтение версии `CANCELLED` → 200, `curator_go` → 200, чужой филиал → 404, `limit=2001` → 422; `summary.gaps` при пробеле; суммы в JSON — строки `"100.00"`. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidated_osv.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами`. +- [x] **Step 1: Тесты.** Integration на `make_confirmed_osv` ×3 квартала ×2 ЮЛ: страница, `total`, сценарии «Только несопоставленные», «Фильтр по способу», `q` по имени/ИНН, `legal_entity_id`, `account_root`, чтение версии `CANCELLED` → 200, `curator_go` → 200, чужой филиал → 404, `limit=2001` → 422; `summary.gaps` при пробеле; суммы в JSON — строки `"100.00"`. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidated_osv.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): API сводной ОСВ — страница с фильтрами и сводка с пробелами`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 6db1dec..bc8bfd4 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -241,3 +241,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка | | 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех | | 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись | +| 57 | 2026-10-06 | `OsvFilters` (фильтры) и `OsvPageParams` (`limit` ≤ 2000, по умолчанию 500; `offset` ≥ 0) — отдельные модели; для маршрута `GET …/osv` они сливаются в `OsvQuery(OsvFilters, OsvPageParams)`, потому что FastAPI принимает одну query-модель на маршрут (две модели дают 422 «Field required» по `filters`/`page`); сервису `OsvQuery` передаётся и как фильтры, и как пагинация, экспорт (Task 12) берёт только `OsvFilters` | закрывает T2.7; проверено падением теста до слияния | при ошибке — query-параметры маршрута придётся перегруппировать; контракт API (плоские параметры) не меняется | +| 58 | 2026-10-06 | Суммы `ConsolidatedRowOut` приводятся валидатором к двум знакам (`quantize(Decimal("0.01"))`): `"0.00"`/`"30.00"`, не `"0"`/`"30"` | склейка даёт `Decimal("0")` для отсутствующей в квартале строки, а спека и макет показывают суммы `numeric(18,2)` с двумя знаками (`"100.00"`); значение не меняется (в данных знаков больше двух нет) | при ошибке — лишние/недостающие нули в JSON, суммы остаются точными | +| 59 | 2026-10-06 | `account_sort_key` считает числовым сегмент только из ASCII-цифр (`[0-9]+`), остальные — текстом после числовых; `isdigit()` пропускал `"6²"`/`"①"`, на которых `int()` падает (T9.a) | код счёта приходит из файла; исключение роняло бы всю страницу свода и чек-лист (500) | при ошибке — экзотический код счёта сортируется как текст, а не по значению; падения нет | +| 60 | 2026-10-06 | Идентификаторы кандидатов дорезолва `_build` собирает заранее: `CounterpartyIndex.resolve` (словари в памяти, без БД) по каждой строке без `counterparty_id`, плюс хранимые `counterparty_id`; один `SELECT legal_entity … WHERE id IN (…)` без policy; члены периметра берутся из `members` и в запрос не входят. Запросов к БД на `GET …/osv` — константа (8 SELECT на 5 400 исходных строк), без N+1; страница режется в памяти после `domain.build` и `apply_filter` | свод — чистая функция версии (D-1), срез и фильтры по `counterparty_type`/`unmapped` зависят от дорезолва, поэтому строить нужно весь свод | при ошибке — `IN` с очень большим числом контрагентов упрётся в лимит параметров asyncpg (32 767); число различных контрагентов версии на порядки меньше | +| 61 | 2026-10-06 | `ConsolidatedOsvService` только читает: без блокировок и аудита, версия любого статуса (включая `CANCELLED`) читается; `documents` для `build` — привязанные документы `document_type = OSV` (`bound_documents`), `rows` — ровно результат `load_osv_rows` (`COUNTERPARTY`, `BU`/`SUM`): домен `group_type`/`data_type` не фильтрует (замечание ревью Task 9) | спека «Страница свода и сводка»; DECISION-5 | при ошибке — в свод попали бы строки `ACCOUNT`/`NU` и задвоили бы суммы | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 4c2e5c1..9f3e17d 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -46,7 +46,7 @@ - [x] 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`. Тесты: страница на +- [x] 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`, ИНН-противоречие, аудит). -- 2.47.2 From e246531c70abb9a02a97f668c158051987fbb2b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 06:46:06 +0400 Subject: [PATCH 17/27] =?UTF-8?q?feat(consolidation):=20=D0=BA=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B8=20=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B0=D1=81=D0=BE=D0=BC=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET …/counterparty-candidates (scope group|all, QUERY_TOO_SHORT, recommended по нормализованному имени строки) и POST …/osv/entries/{entry_id}/counterparty: проверка строки свода версии, ЮЛ без policy, ALIAS_TAKEN с owners / перенос алиаса, MANUAL строке и PROPAGATED строкам с тем же strip()-именем без противоречия ИНН, один OSV_COUNTERPARTY_MAP и LEGAL_ENTITY_CHANGE на каждый алиас. Строки общие для версий блокируются FOR NO KEY UPDATE ORDER BY id; advisory-блокировка алиаса общая со справочником. Решения #62–#70 в design.md; 4.3 отмечена. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 6 +- api/src/api/dependencies/consolidations.py | 13 + .../api/endpoints/consolidations/__init__.py | 3 +- .../api/endpoints/consolidations/mapping.py | 73 ++ api/src/repositories/consolidations.py | 105 ++- api/src/repositories/directories.py | 25 + api/src/schemas/consolidations.py | 8 +- api/src/services/aliases.py | 22 +- api/src/services/consolidated_osv.py | 100 ++- api/src/services/counterparty_mapping.py | 187 ++++ api/tests/test_api_counterparty_mapping.py | 822 ++++++++++++++++++ api/tests/test_openapi_routes.py | 19 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 9 + .../changes/slice-4-consolidation/tasks.md | 2 +- 15 files changed, 1374 insertions(+), 26 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/mapping.py create mode 100644 api/src/services/counterparty_mapping.py create mode 100644 api/tests/test_api_counterparty_mapping.py diff --git a/README.md b/README.md index 65c0f14..2074e1f 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,9 @@ S3 (storage); без S3 — предупреждение в логе, цикл `MEMBER_EXISTS`/`MEMBER_OF_OTHER_GROUP`. В теле гонки — только код и постоянный текст, без дополнительных полей (`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс -«ЮЛ + вид»); гонка за один алиас у разных ЮЛ (`ALIAS_TAKEN`) в БД не защищена (уникален только `(ЮЛ, алиас)`); +«ЮЛ + вид»); гонку за один алиас у разных ЮЛ (`ALIAS_TAKEN`) БД не ловит (уникален только `(ЮЛ, алиас)`) — её +сериализует advisory-блокировка по нормализованному алиасу (`lock_alias_key`: добавление алиаса и ручное сопоставление +контрагента); email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением. Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка `нарушение целостности данных в БД`. @@ -588,7 +590,7 @@ email без учёта регистра проверяет только сер | `MEMBER_EXISTS` | 409 | ЮЛ уже входит в эту группу | | `MEMBER_OF_OTHER_GROUP` | 409 | ЮЛ уже входит в другую группу этого вида (`related_group_id`) | | `ALIAS_EXISTS` | 409 | у ЮЛ уже есть такой алиас | -| `ALIAS_TAKEN` | 409 | алиас закреплён за другим ЮЛ (`legal_entity_id`) | +| `ALIAS_TAKEN` | 409 | алиас закреплён за другим ЮЛ (`legal_entity_id`; в ручном сопоставлении контрагента — `owners[] {legal_entity_id, name}`) | | `EMAIL_EXISTS` | 409 | пользователь с таким email уже есть | | `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` | | `UNSUPPORTED_FILE` | 422 | загрузка: тип не из xls/xlsx/xlsb/pdf/png/jpg, расширение не совпало с сигнатурой, пустое имя; строка `rejected[]` ответа загрузки (при импорте — файл целиком) | diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 7dcd637..fdf0e56 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -13,6 +13,7 @@ from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService +from src.services.counterparty_mapping import CounterpartyMappingService def get_consolidation_service( @@ -60,3 +61,15 @@ def get_consolidated_osv_service( ConsolidatedOsvServiceDep = Annotated[ConsolidatedOsvService, Depends(get_consolidated_osv_service)] + + +def get_counterparty_mapping_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> CounterpartyMappingService: + """Сервис ручного сопоставления контрагента строки свода (правка версии: блокировка и аудит).""" + return CounterpartyMappingService(session, audit, scope) + + +CounterpartyMappingServiceDep = Annotated[CounterpartyMappingService, Depends(get_counterparty_mapping_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 34601ef..95f13c9 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import checklist, locks, members, osv, periods, status, versions +from src.api.endpoints.consolidations import checklist, locks, mapping, members, osv, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -12,3 +12,4 @@ router.include_router(status.router) router.include_router(members.router) router.include_router(checklist.router) router.include_router(osv.router) +router.include_router(mapping.router) diff --git a/api/src/api/endpoints/consolidations/mapping.py b/api/src/api/endpoints/consolidations/mapping.py new file mode 100644 index 0000000..b4dc90b --- /dev/null +++ b/api/src/api/endpoints/consolidations/mapping.py @@ -0,0 +1,73 @@ +"""Кандидаты и ручное сопоставление контрагента строки свода (spec consolidated-osv «Кандидаты для сопоставления», +«Ручное сопоставление контрагента»; audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10, D-11). + +Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у несуществующей. Кандидаты читаются в +любом статусе версии; сопоставление — правка версии: статус «в работе» и своя блокировка (409). +""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep, CounterpartyMappingServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import CandidateOut, MapCounterpartyIn, MapCounterpartyOut +from src.services.consolidated_osv import CandidateScope + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +MAX_QUERY_LENGTH = 500 +CANDIDATES_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "версия не существует или вне видимости"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "QUERY_TOO_SHORT (scope=all) или ошибка параметров"}, +} +MAP_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: { + "model": ErrorDetail, + "description": "версия вне видимости; строка не принадлежит версии; ЮЛ не найдено", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorDetail, + "description": "VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER, ALIAS_TAKEN (owners)", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "ошибка валидации тела"}, +} + + +@router.get( + "/{version_id}/counterparty-candidates", + response_model=list[CandidateOut], + dependencies=VIEW, + responses=CANDIDATES_RESPONSES, + summary="Кандидаты для сопоставления контрагента", +) +async def list_candidates( + version_id: uuid.UUID, + service: ConsolidatedOsvServiceDep, + q: Annotated[str | None, Query(max_length=MAX_QUERY_LENGTH)] = None, + scope: CandidateScope = "group", + entry_id: uuid.UUID | None = None, +) -> list[CandidateOut]: + """До 100 ЮЛ: `scope=group` — периметр и ГК консолидации (`q` необязателен), `scope=all` — весь справочник без + policy (`q` не короче 3 символов). Поиск: ИНН по префиксу, имя и алиасы — подстрока без регистра. `recommended` — + по нормализованному имени строки `entry_id`; рекомендуемые первыми, затем по имени.""" + return await service.candidates(version_id, q=q, scope=scope, entry_id=entry_id) + + +@router.post( + "/{version_id}/osv/entries/{entry_id}/counterparty", + response_model=MapCounterpartyOut, + dependencies=EDIT, + responses=MAP_RESPONSES, + summary="Сопоставить контрагента строки свода", +) +async def map_counterparty( + version_id: uuid.UUID, entry_id: uuid.UUID, data: MapCounterpartyIn, service: CounterpartyMappingServiceDep +) -> MapCounterpartyOut: + """Связывает строку с ЮЛ (`MANUAL`), создаёт алиас имени строки (`move_alias` — забрать у другого ЮЛ) и + автоприменяет выбор к строкам версии с тем же именем (`PROPAGATED`). `row = null` — строка в своде не участвует.""" + return await service.map(version_id, entry_id, data) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index da066d2..92dc0cf 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -10,7 +10,7 @@ import uuid from collections import defaultdict from collections.abc import Collection, Sequence -from sqlalchemy import Select, exists, false, func, select +from sqlalchemy import Select, case, exists, false, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState @@ -26,23 +26,28 @@ from src.models.consolidation import ( ConsolidationStatusHistory, ConsolidationVersion, ) -from src.models.directories import CompanyGroup, LegalEntity +from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, RelatedGroup, RelatedGroupMember from src.models.enums import ( ConsolidationApproach, ConsolidationStatus, DocumentStatus, + DocumentType, FileStatus, OsvDataType, OsvGroupType, PeriodQuarter, + RelatedGroupKind, ) from src.models.files import Document, StoredFile from src.models.osv import OsvEntry +from src.repositories.directories import LIKE_ESCAPE, escape_like FILES_IN_PROGRESS = (FileStatus.UPLOADED, FileStatus.PROCESSING) GROUP_BINDINGS_LOCK_PREFIX = "consolidation-group" # пространство ключей advisory-блокировки привязок ГК (R-2) SUMMARY_DATA_TYPES = (OsvDataType.BU, OsvDataType.SUM) UNCONFIRMED_STATUSES = (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK) +# Пробельные символы `str.strip()` (`str.isspace`): `btrim` в SQL срезает ровно их, как `.strip()` склейки свода (D-2) +PY_WHITESPACE = "".join(char for char in map(chr, range(0x3001)) if char.isspace()) def _visible_consolidations(scope: VisibilityScope) -> Select[tuple[Consolidation]]: @@ -341,3 +346,99 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu ) ) return list((await session.execute(stmt)).scalars()) + + +def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]: + """Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии.""" + return ( + select(OsvEntry) + .join(Document, Document.id == OsvEntry.document_id) + .join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id) + .where( + ConsolidationDocument.consolidation_version_id == version_id, + Document.document_type == DocumentType.OSV, + OsvEntry.group_type == OsvGroupType.COUNTERPARTY, + OsvEntry.data_type.in_(SUMMARY_DATA_TYPES), + ) + ) + + +async def entry_in_version(session: AsyncSession, entry_id: uuid.UUID, version_id: uuid.UUID) -> OsvEntry | None: + """Строка `entry_id`, если она — строка свода версии (`_summary_entries`): документ ОСВ привязан к версии, строка + `COUNTERPARTY` с данными BU/SUM. Иначе (непривязанный документ, `ACCOUNT`, `NU`, чужая строка) — `None`.""" + stmt = _summary_entries(version_id).where(OsvEntry.id == entry_id) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def lock_entries_same_name( + session: AsyncSession, version_id: uuid.UUID, *, entry_id: uuid.UUID, name: str +) -> list[OsvEntry]: + """Строка `entry_id` и строки свода версии с тем же `counterparty_name.strip()` = `name` под + `FOR NO KEY UPDATE ORDER BY id` (ручное сопоставление, D-10); пустой `name` — только сама строка. + + Строки ОСВ общие для всех версий, к которым привязан документ (OQ-123): сопоставления в двух версиях блокируют + пересекающиеся множества строк, порядок по `id` исключает взаимоблокировку, а `populate_existing` отдаёт + состояние после ожидания (READ COMMITTED перечитывает заблокированную строку), поэтому `counterparty_id IS NULL` + проверяется по свежим данным. Блокируется только `osv_entry` (документы и привязки не трогаются).""" + same = OsvEntry.id == entry_id + if name: + same = or_(same, func.btrim(OsvEntry.counterparty_name, PY_WHITESPACE) == name) + stmt = ( + _summary_entries(version_id) + .where(same) + .order_by(OsvEntry.id) + .with_for_update(of=OsvEntry, key_share=True) + .execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) + + +async def search_entities( # noqa: PLR0913 - фильтры поиска кандидатов передаются явными именованными аргументами + session: AsyncSession, + *, + q: str | None, + group_id: uuid.UUID | None, + member_ids: Collection[uuid.UUID], + recommended_ids: Collection[uuid.UUID], + limit: int, +) -> list[tuple[LegalEntity, CompanyGroup | None]]: + """Кандидаты для ручного сопоставления (D-11) без policy видимости, с ГК каждого ЮЛ. + + `group_id` задан — ЮЛ этой ГК и `member_ids` (периметр версии); `None` — весь справочник. `q` (уже без краевых + пробелов): ИНН по префиксу (пробелы внутри `q` не учитываются), имя или алиас — подстрока без регистра; шаблоны + LIKE в `q` экранируются. Порядок: `recommended_ids` первыми, затем имя и `id`; не больше `limit`. + """ + stmt = select(LegalEntity, CompanyGroup).outerjoin(CompanyGroup, CompanyGroup.id == LegalEntity.company_group_id) + if group_id is not None: + in_scope = LegalEntity.company_group_id == group_id + stmt = stmt.where(or_(in_scope, LegalEntity.id.in_(list(member_ids))) if member_ids else in_scope) + if q: + pattern = f"%{escape_like(q)}%" + by_alias = exists().where( + LegalEntityAlias.legal_entity_id == LegalEntity.id, + LegalEntityAlias.alias.ilike(pattern, escape=LIKE_ESCAPE), + ) + conditions = [LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), by_alias] + if inn := "".join(q.split()): + conditions.append(LegalEntity.inn.like(f"{escape_like(inn)}%", escape=LIKE_ESCAPE)) + stmt = stmt.where(or_(*conditions)) + first = case((LegalEntity.id.in_(list(recommended_ids)), 0), else_=1) if recommended_ids else None + order = [LegalEntity.name, LegalEntity.id] if first is None else [first, LegalEntity.name, LegalEntity.id] + return [(row[0], row[1]) for row in await session.execute(stmt.order_by(*order).limit(limit))] + + +async def current_related_groups( + session: AsyncSession, entity_ids: Collection[uuid.UUID] +) -> dict[uuid.UUID, dict[RelatedGroupKind, RelatedGroup]]: + """Текущие ГСК/ГСЗ каждого ЮЛ из `entity_ids` одним запросом: ЮЛ → вид → группа (не больше одной каждого вида).""" + if not entity_ids: + return {} + stmt = ( + select(RelatedGroupMember.legal_entity_id, RelatedGroup) + .join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id) + .where(RelatedGroupMember.legal_entity_id.in_(entity_ids), RelatedGroupMember.is_current.is_(True)) + ) + groups: dict[uuid.UUID, dict[RelatedGroupKind, RelatedGroup]] = defaultdict(dict) + for entity_id, group in await session.execute(stmt): + groups[entity_id][group.kind] = group + return dict(groups) diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 0eea753..68d25f7 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -27,6 +27,7 @@ from src.models.directories import ( from src.models.enums import RelatedGroupKind LIKE_ESCAPE = "\\" +ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов def escape_like(text: str) -> str: @@ -356,6 +357,30 @@ async def find_alias_owner(session: AsyncSession, normalized: str) -> LegalEntit return (await session.execute(stmt)).scalar_one_or_none() +async def find_alias_owners(session: AsyncSession, normalized: str) -> list[tuple[LegalEntityAlias, str]]: + """Все алиасы с таким `normalized_alias` (у любых ЮЛ, без фильтра видимости) и имена их владельцев — по времени + добавления; строки алиасов — `FOR UPDATE` (параллельное удаление алиаса в справочнике дождётся нас или мы — его и + не увидим удалённого). Уникальность в БД — только в пределах ЮЛ, поэтому владельцев может быть несколько.""" + stmt = ( + select(LegalEntityAlias, LegalEntity.name) + .join(LegalEntity, LegalEntity.id == LegalEntityAlias.legal_entity_id) + .where(LegalEntityAlias.normalized_alias == normalized) + .order_by(LegalEntityAlias.created_at, LegalEntityAlias.id) + .with_for_update(of=LegalEntityAlias) + .execution_options(populate_existing=True) + ) + return [(row[0], row[1]) for row in await session.execute(stmt)] + + +async def lock_alias_key(session: AsyncSession, normalized: str) -> None: + """`pg_advisory_xact_lock` по нормализованному алиасу: сериализует проверку «алиас свободен» и вставку у всех + писателей алиасов (`aliases.add`, ручное сопоставление контрагента). Глобальной уникальности `normalized_alias` + в БД нет (индекс — в пределах ЮЛ), без блокировки два параллельных запроса закрепили бы алиас за двумя ЮЛ. + Снимается в конце транзакции.""" + key = func.hashtextextended(f"{ALIAS_LOCK_PREFIX}:{normalized}", 0) + await session.execute(select(func.pg_advisory_xact_lock(key))) + + async def get_alias(session: AsyncSession, entity_id: uuid.UUID, alias_id: uuid.UUID) -> LegalEntityAlias | None: """Алиас по id, только если он принадлежит ЮЛ `entity_id`.""" stmt = select(LegalEntityAlias).where( diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 036b36b..ee2084a 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -277,9 +277,13 @@ class MapCounterpartyIn(BaseModel): class MapCounterpartyOut(BaseModel): - """Результат ручного сопоставления: обновлённая строка свода, число строк автоприменения, судьба алиаса.""" + """Результат ручного сопоставления: обновлённая строка свода, число строк автоприменения, судьба алиаса. - row: ConsolidatedRowOut + `row = null` — строка в своде не участвует (пара с пробелом или документ не выбран источником), сопоставление + при этом записано. `alias_moved_from` — прежний владелец перенесённого алиаса (первый по времени добавления). + """ + + row: ConsolidatedRowOut | None propagated: int alias_created: bool alias_moved_from: uuid.UUID | None diff --git a/api/src/services/aliases.py b/api/src/services/aliases.py index f539c3d..c4589aa 100644 --- a/api/src/services/aliases.py +++ b/api/src/services/aliases.py @@ -23,6 +23,15 @@ OBJECT_TYPE = "legal_entity_alias" ALIAS_NOT_FOUND = "Алиас не найден" +def alias_payload(alias: LegalEntityAlias) -> dict[str, object]: + """`before`/`after` события `LEGAL_ENTITY_CHANGE` по алиасу (одно определение для справочника и сопоставления).""" + return { + "legal_entity_id": alias.legal_entity_id, + "alias": alias.alias, + "normalized_alias": alias.normalized_alias, + } + + class AliasService: """Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости.""" @@ -37,6 +46,7 @@ class AliasService: normalized = normalize_alias(data.alias) if not normalized: raise DomainValidationError("EMPTY_ALIAS", "Алиас пуст после нормализации (одни ОПФ и знаки препинания)") + await repo.lock_alias_key(self._session, normalized) # «проверка → вставка» без гонки с ручным сопоставлением owner = await repo.find_alias_owner(self._session, normalized) if owner is not None: if owner.legal_entity_id == entity.id: @@ -59,7 +69,7 @@ class AliasService: section=AuditSection.DIRECTORIES, object_type=OBJECT_TYPE, object_id=alias.id, - after_value=self._payload(alias), + after_value=alias_payload(alias), ) return alias @@ -69,7 +79,7 @@ class AliasService: alias = await repo.get_alias(self._session, entity.id, alias_id) if alias is None: raise NotFoundError(ALIAS_NOT_FOUND) - before = self._payload(alias) + before = alias_payload(alias) await self._session.delete(alias) await self._session.flush() await self._audit.record( @@ -85,11 +95,3 @@ class AliasService: if entity is None: raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) return entity - - @staticmethod - def _payload(alias: LegalEntityAlias) -> dict[str, object]: - return { - "legal_entity_id": alias.legal_entity_id, - "alias": alias.alias, - "normalized_alias": alias.normalized_alias, - } diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 45aec0d..65b213d 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -13,25 +13,39 @@ import uuid from collections.abc import Collection, Sequence +from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.common.exceptions import NotFoundError +from src.common.exceptions import DomainValidationError, NotFoundError from src.domain import consolidated_osv as domain +from src.domain.aliases import normalize_alias from src.domain.policy import VisibilityScope from src.models.consolidation import Consolidation, ConsolidationVersion -from src.models.directories import LegalEntity -from src.models.enums import DocumentType +from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup +from src.models.enums import DocumentType, RelatedGroupKind from src.models.files import Document from src.models.osv import OsvEntry from src.repositories import consolidations as repo from src.repositories.counterparties import CounterpartyIndex from src.schemas.common import Page -from src.schemas.consolidations import ConsolidatedRowOut, GapOut, OsvFilters, OsvPageParams, OsvSummaryOut +from src.schemas.consolidations import ( + CandidateOut, + ConsolidatedRowOut, + GapOut, + OsvFilters, + OsvPageParams, + OsvSummaryOut, + RefOut, +) from src.services.consolidation_guard import VERSION_NOT_FOUND -__all__ = ["ConsolidatedOsvService", "domain_filter", "row_out"] +__all__ = ["MAX_CANDIDATES", "MIN_QUERY_ALL", "CandidateScope", "ConsolidatedOsvService", "domain_filter", "row_out"] + +CandidateScope = Literal["group", "all"] +MAX_CANDIDATES = 100 # кандидатов в ответе не больше (D-11) +MIN_QUERY_ALL = 3 # `scope=all`: поиск по всему справочнику — от трёх символов (решение №5) def row_out(row: domain.ConsolidatedRow) -> ConsolidatedRowOut: @@ -55,6 +69,10 @@ def _entity(entity: LegalEntity) -> domain.Entity: return domain.Entity(id=entity.id, name=entity.name, inn=entity.inn, company_group_id=entity.company_group_id) +def _ref(item: CompanyGroup | RelatedGroup | None) -> RefOut | None: + return None if item is None else RefOut(id=item.id, name=item.name) + + def _source_document(document: Document) -> domain.SourceDocument: return domain.SourceDocument( id=document.id, @@ -161,3 +179,75 @@ class ConsolidatedOsvService: documents_total=consolidated.documents_total, gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps], ) + + async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: + """Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом + или документ не выбран источником при наличии нарастающего).""" + consolidated, _, _ = await self._build(version_id) + row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None) + return None if row is None else row_out(row) + + async def candidates( + self, version_id: uuid.UUID, *, q: str | None, scope: CandidateScope, entry_id: uuid.UUID | None + ) -> list[CandidateOut]: + """Кандидаты для ручного сопоставления контрагента (spec consolidated-osv «Кандидаты для сопоставления», D-11). + + Версия — через видимость (404). `group` — ЮЛ периметра и ГК консолидации, `q` необязателен; `all` — весь + справочник без policy, `q` без краевых пробелов не короче `MIN_QUERY_ALL` (иначе 422 `QUERY_TOO_SHORT`). + `recommended` — нормализованное имя или алиас ЮЛ совпали с `normalize_alias` имени строки `entry_id` (строка + свода этой версии; иначе рекомендаций нет); рекомендуемые первыми, затем по имени; не больше `MAX_CANDIDATES`. + Запросов — константа: версия, периметр, (индекс контрагентов при `entry_id`), поиск с ГК, членства ГСК/ГСЗ. + """ + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, consolidation = found + query = (q or "").strip() or None + if scope == "all" and (query is None or len(query) < MIN_QUERY_ALL): + raise DomainValidationError( + "QUERY_TOO_SHORT", f"Для поиска по всему справочнику нужно не меньше {MIN_QUERY_ALL} символов" + ) + members = {entity.id: _entity(entity) for _, entity in await repo.members(self._session, version.id)} + recommended = await self._recommended_ids(version.id, entry_id) + found_entities = await repo.search_entities( + self._session, + q=query, + group_id=consolidation.company_group_id if scope == "group" else None, + member_ids=list(members), + recommended_ids=recommended, + limit=MAX_CANDIDATES, + ) + related = await repo.current_related_groups(self._session, [entity.id for entity, _ in found_entities]) + result = [] + for entity, group in found_entities: + groups = related.get(entity.id, {}) + kind = domain.classify( + entity.id, members=members, entities={entity.id: _entity(entity)}, + group_id=consolidation.company_group_id, + ) # fmt: skip + result.append( + CandidateOut( + id=entity.id, + inn=entity.inn, + name=entity.name, + company_group=_ref(group), + gsk=_ref(groups.get(RelatedGroupKind.gsk)), + gsz=_ref(groups.get(RelatedGroupKind.gsz)), + counterparty_type=kind, + recommended=entity.id in recommended, + ) + ) + return result + + async def _recommended_ids(self, version_id: uuid.UUID, entry_id: uuid.UUID | None) -> set[uuid.UUID]: + """ЮЛ, чьё нормализованное имя или алиас равны `normalize_alias` имени строки `entry_id` (строка свода версии). + + Нормализация — функция Python (§3 п. 4), поэтому совпадения ищутся тем же `CounterpartyIndex`, что резолвит + строки свода (`by_normalized` — имена и алиасы всего справочника без policy).""" + if entry_id is None: + return set() + entry = await repo.entry_in_version(self._session, entry_id, version_id) + if entry is None or not (normalized := normalize_alias(entry.counterparty_name or "")): + return set() + index = await CounterpartyIndex.load(self._session) + return set(index.by_normalized.get(normalized, ())) diff --git a/api/src/services/counterparty_mapping.py b/api/src/services/counterparty_mapping.py new file mode 100644 index 0000000..af5d50d --- /dev/null +++ b/api/src/services/counterparty_mapping.py @@ -0,0 +1,187 @@ +"""Ручное сопоставление контрагента строки сводной ОСВ (spec consolidated-osv «Ручное сопоставление контрагента»; +audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10; §3 «Ручное сопоставление»). + +Одна транзакция, порядок проверок и блокировок: +1. `lock_editable_version` — версия `FOR UPDATE` → видимость (404) → статус «в работе» и своя блокировка (409). +2. Строка — строка свода версии (`entry_in_version`, иначе 404 «строка не найдена»); ЮЛ существует (без policy: + контрагент может быть из другого филиала; иначе 404 `LEGAL_ENTITY_NOT_FOUND`). +3. `normalized = normalize_alias(имя строки)`; непустой — advisory-блокировка алиаса (`lock_alias_key`, общая со + справочником) и владельцы алиаса среди других ЮЛ: без `move_alias` — 409 `ALIAS_TAKEN` (`owners[]`), с ним — их + алиасы удаляются (`LEGAL_ENTITY_CHANGE` на каждый). `DIRECTORY_DELETE` не требуется (решение владельца 06.10.2026). +4. Строки с тем же `counterparty_name.strip()` (и сама строка) — `FOR NO KEY UPDATE ORDER BY id` + (`lock_entries_same_name`): строки ОСВ общие для всех версий документа (OQ-123), параллельное сопоставление в + соседней версии ждёт и видит наш результат. +5. Алиаса у выбранного ЮЛ нет и `normalized` непуст → алиас `counterparty_name.strip()` (`LEGAL_ENTITY_CHANGE`). +6. Строке — `counterparty_id`, `MANUAL`; автоприменение — строкам свода версии с тем же именем, пустым + `counterparty_id` и ИНН пустым или равным ИНН выбранного ЮЛ → `PROPAGATED`. `counterparty_name/inn`, + `modified_by/at`, `is_modified` не трогаются (первичка не мутируется; сопоставление — не правка значений). +7. Одно `OSV_COUNTERPARTY_MAP` с `consolidation_version_id`; ответ — строка свода, пересчитанная заново. +""" + +import uuid +from typing import Any + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.constraint_conflicts import constraint_name +from src.common.exceptions import ConflictError, NotFoundError +from src.domain.aliases import normalize_alias +from src.domain.inn import normalize_inn +from src.domain.policy import VisibilityScope +from src.models.directories import LegalEntity, LegalEntityAlias +from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind +from src.models.osv import OsvEntry +from src.repositories import consolidations as repo +from src.repositories import directories as directories_repo +from src.schemas.consolidations import MapCounterpartyIn, MapCounterpartyOut +from src.services.aliases import OBJECT_TYPE as ALIAS_OBJECT_TYPE +from src.services.aliases import alias_payload +from src.services.consolidated_osv import ConsolidatedOsvService +from src.services.consolidation_guard import lock_editable_version +from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND + +ENTRY_NOT_FOUND = "строка ОСВ не найдена в версии" # непривязанный документ, не строка свода, несуществующая +OBJECT_TYPE = "osv_entry" +ALIAS_UNIQUE_INDEX = "uq_legal_entity_alias_legal_entity_id_normalized_alias" + + +def inn_agrees(entry_inn: str | None, target_inn: str) -> bool: + """ИНН строки не противоречит выбранному ЮЛ: пустой (после удаления пробелов) или равный его ИНН.""" + inn = normalize_inn(entry_inn or "") + return not inn or inn == normalize_inn(target_inn) + + +class CounterpartyMappingService: + """Ручное сопоставление контрагента строки свода от имени текущего пользователя.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def map(self, version_id: uuid.UUID, entry_id: uuid.UUID, data: MapCounterpartyIn) -> MapCounterpartyOut: + """Связывает строку `entry_id` версии с ЮЛ `data.legal_entity_id` (порядок — в docstring модуля).""" + version, _ = await lock_editable_version(self._session, self._scope, version_id, self._audit.user_id) + entry = await repo.entry_in_version(self._session, entry_id, version.id) + if entry is None: + raise NotFoundError(ENTRY_NOT_FOUND) + target = await self._session.get(LegalEntity, data.legal_entity_id) + if target is None: + raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) + name = (entry.counterparty_name or "").strip() + normalized = normalize_alias(name) + + owners: list[tuple[LegalEntityAlias, str]] = [] + target_has_alias = False + if normalized: + await directories_repo.lock_alias_key(self._session, normalized) + for alias, owner_name in await directories_repo.find_alias_owners(self._session, normalized): + if alias.legal_entity_id == target.id: + target_has_alias = True + else: + owners.append((alias, owner_name)) + if owners and not data.move_alias: + raise ConflictError( + "ALIAS_TAKEN", + "Такое наименование уже закреплено алиасом за другим ЮЛ", + owners=[{"legal_entity_id": alias.legal_entity_id, "name": owner} for alias, owner in owners], + ) + + locked = await repo.lock_entries_same_name(self._session, version.id, entry_id=entry.id, name=name) + before = {"counterparty_id": entry.counterparty_id, "match_kind": entry.counterparty_match_kind} + + for alias, _ in owners: + await self._remove_alias(alias, version.id) + alias_created = bool(normalized) and not target_has_alias and await self._create_alias(target, name, version.id) + + entry.counterparty_id = target.id + entry.counterparty_match_kind = CounterpartyMatchKind.MANUAL + propagated = [row for row in locked if self._propagates(row, entry, name, target)] + for row in propagated: + row.counterparty_id = target.id + row.counterparty_match_kind = CounterpartyMatchKind.PROPAGATED + await self._session.flush() + + moved_from = owners[0][0].legal_entity_id if owners else None + await self._audit.record( + action=AuditAction.OSV_COUNTERPARTY_MAP, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=entry.id, + consolidation_version_id=version.id, + before_value=before, + after_value={ + "counterparty_id": target.id, + "match_kind": CounterpartyMatchKind.MANUAL, + "propagated_entry_ids": [row.id for row in propagated], + "alias_created": alias_created, + "alias_moved_from": moved_from, + }, + ) + row_out = await ConsolidatedOsvService(self._session, self._scope).row_for_entry(version.id, entry.id) + return MapCounterpartyOut( + row=row_out, propagated=len(propagated), alias_created=alias_created, alias_moved_from=moved_from + ) + + @staticmethod + def _propagates(row: OsvEntry, entry: OsvEntry, name: str, target: LegalEntity) -> bool: + """Автоприменение (D-10): другая строка, то же имя после `strip()`, не сопоставлена, ИНН не противоречит.""" + return ( + row.id != entry.id + and bool(name) + and (row.counterparty_name or "").strip() == name + and row.counterparty_id is None + and inn_agrees(row.counterparty_inn, target.inn) + ) + + async def _remove_alias(self, alias: LegalEntityAlias, version_id: uuid.UUID) -> None: + """Удаляет чужой алиас (перенос, `move_alias`); `LEGAL_ENTITY_CHANGE` как у `aliases.remove`.""" + before = alias_payload(alias) + alias_id = alias.id + await self._session.delete(alias) + await self._session.flush() + await self._record_alias_change(alias_id, version_id, before=before) + + async def _create_alias(self, target: LegalEntity, name: str, version_id: uuid.UUID) -> bool: + """Алиас `name` у выбранного ЮЛ под SAVEPOINT; `LEGAL_ENTITY_CHANGE` как у `aliases.add`. + + Вставку конкурирующих писателей сериализует `lock_alias_key`; уникальный индекс (ЮЛ, `normalized_alias`) — + последний рубеж: его нарушение значит «алиас у ЮЛ уже есть» — `False`, сопоставление продолжается.""" + alias = LegalEntityAlias( + legal_entity_id=target.id, + alias=name, + normalized_alias=normalize_alias(name), + created_by=self._audit.user_id, + created_at=utc_now(), + ) + try: + async with self._session.begin_nested(): + self._session.add(alias) + await self._session.flush() + except IntegrityError as exc: + if constraint_name(exc) != ALIAS_UNIQUE_INDEX: + raise + return False + await self._record_alias_change(alias.id, version_id, after=alias_payload(alias)) + return True + + async def _record_alias_change( + self, + alias_id: uuid.UUID, + version_id: uuid.UUID, + *, + before: dict[str, Any] | None = None, + after: dict[str, Any] | None = None, + ) -> None: + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=ALIAS_OBJECT_TYPE, + object_id=alias_id, + consolidation_version_id=version_id, + before_value=before, + after_value=after, + ) diff --git a/api/tests/test_api_counterparty_mapping.py b/api/tests/test_api_counterparty_mapping.py new file mode 100644 index 0000000..5c6517b --- /dev/null +++ b/api/tests/test_api_counterparty_mapping.py @@ -0,0 +1,822 @@ +"""Кандидаты и ручное сопоставление контрагента (spec consolidated-osv «Кандидаты для сопоставления», «Ручное +сопоставление контрагента»; audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10, D-11). + +Мир теста — версия РБ 2024 Q3 (периметр {АО Русский бисквит, ООО «Пекарня Счастливочная»}, ЧКФ — в ГК вне +периметра); у РБ нарастающие ОСВ 60/62/66/76 за Q3 со строкой «Пекарня Счастливочная ООО» без ИНН. Тёзка +«АО Пекарня Счастливочная» из ГК Краснодарского филиала делает резолв по нормализованному имени неоднозначным, поэтому +строки РБ до ручного сопоставления не сопоставлены (как на фикстурах, сценарий «Признаки на фикстурах РБ»). +""" + +import asyncio +import uuid +from dataclasses import dataclass, field +from typing import Any + +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.api.dependencies.audit import RequestAudit +from src.domain.policy import VisibilityScope +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, LegalEntityAlias, RegionalOffice +from src.models.enums import ( + AuditAction, + AuditSection, + ConsolidationStatus, + CounterpartyMatchKind, + OsvDataType, + PeriodQuarter, + RelatedGroupKind, +) +from src.models.files import Document +from src.models.osv import OsvEntry +from src.schemas.consolidations import MapCounterpartyIn +from src.services.audit import AuditService +from src.services.counterparty_mapping import CounterpartyMappingService +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_confirmed_osv, + make_consolidation, + make_legal_entity, + make_lock, + make_membership, + make_related_group, +) + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" +CURATOR = "curator_go@armka.local" +EXPERT = "expert@armka.local" +FOREIGN = "client_manager@armka.local" # Краснодарский филиал +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +NAME = "Пекарня Счастливочная ООО" +ALIEN_INN = "7700000000" +MAX_CANDIDATES = 100 +LOCK_WAIT_SECONDS = 10.0 + + +@dataclass +class World: + """Версия РБ 2024 Q3 и её участники; `docs` — документы по счёту, `entries` — строки по ключу (счёт, номер).""" + + version: ConsolidationVersion + group: CompanyGroup + rb: LegalEntity + schastl: LegalEntity + chkf: LegalEntity + namesake: LegalEntity + docs: dict[str, Document] = field(default_factory=dict) + entries: dict[tuple[str, int], uuid.UUID] = field(default_factory=dict) + gsk_id: uuid.UUID | None = None + gsz_id: uuid.UUID | None = None + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +async def _bind(session: AsyncSession, version: ConsolidationVersion, *documents: Document) -> None: + for document in documents: + session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await session.flush() + + +async def _entries_of(session: AsyncSession, document: Document) -> list[uuid.UUID]: + stmt = select(OsvEntry.id).where(OsvEntry.document_id == document.id).order_by(OsvEntry.row_number) + return list((await session.execute(stmt)).scalars()) + + +async def _world( # noqa: PLR0913 - тестовая фабрика: статус, владелец блокировки и филиал версии + session: AsyncSession, + *, + status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION, + locked_by: str | None = KA_RF, + office_name: str = ROSTOV, +) -> World: + group = await make_company_group(session, name="ГК Русский бисквит", office=await _office(session, office_name)) + rb = await make_legal_entity(session, name="АО Русский бисквит", group=group) + schastl = await make_legal_entity(session, name="ООО «Пекарня Счастливочная»", group=group) + chkf = await make_legal_entity(session, name="ООО ЧКФ", group=group) + other_group = await make_company_group(session, name="ГК Кубань", office=await _office(session, KRASNODAR)) + namesake = await make_legal_entity(session, name="АО Пекарня Счастливочная", group=other_group) + gsk = await make_related_group(session, group=group, name="ГСК Бисквит") + await make_membership(session, related_group=gsk, entity=rb) + await make_membership(session, related_group=gsk, entity=schastl) + gsz = await make_related_group(session, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Бисквит") + await make_membership(session, related_group=gsz, entity=chkf) + _, version = await make_consolidation( + session, group=group, year=YEAR, quarter=Q3, members=[rb, schastl], + initiator=await _user(session, KA_RF), status=status, + ) # fmt: skip + if locked_by is not None: + await make_lock(session, version=version, user=await _user(session, locked_by)) + world = World( + version=version, group=group, rb=rb, schastl=schastl, chkf=chkf, namesake=namesake, gsk_id=gsk.id, + gsz_id=gsz.id, + ) # fmt: skip + rows: dict[str, list[dict[str, Any]]] = { + "60": [ + {"name": NAME, "closing_debit": 100}, + {"name": "ООО ЧКФ", "counterparty_id": chkf.id, "counterparty_match_kind": CounterpartyMatchKind.MANUAL}, + {"name": NAME, "data_type": OsvDataType.NU, "closing_debit": 1}, # налоговые данные — не в своде + ], + "62": [{"name": NAME, "closing_credit": 20}], + "66": [{"name": f" {NAME} ", "closing_credit": 30}], # те же имена после strip(), в т.ч. NBSP + "76": [{"name": NAME, "inn": ALIEN_INN, "closing_debit": 40}], # ИНН противоречит выбранному ЮЛ + } + for account, account_rows in rows.items(): + world.docs[account] = await make_confirmed_osv( + session, entity=rb, account=account, year=YEAR, quarter=Q3, is_cumulative=True, rows=account_rows + ) + world.docs["schastl-60"] = await make_confirmed_osv( + session, entity=schastl, account="60", year=YEAR, quarter=Q3, is_cumulative=True, + rows=[{"name": "Русский бисквит", "inn": rb.inn, "counterparty_id": rb.id, + "counterparty_match_kind": CounterpartyMatchKind.INN}], + ) # fmt: skip + await _bind(session, version, *world.docs.values()) + # подтверждённый, но НЕ привязанный к версии документ РБ с тем же именем контрагента + world.docs["unbound-58"] = await make_confirmed_osv( + session, entity=rb, account="58", year=YEAR, quarter=Q3, is_cumulative=True, rows=[{"name": NAME}] + ) + for account, document in world.docs.items(): + for number, entry_id in enumerate(await _entries_of(session, document), start=1): + world.entries[(account, number)] = entry_id + return world + + +def _map_url(world: World, entry_id: uuid.UUID, version_id: uuid.UUID | None = None) -> str: + return f"/consolidations/versions/{version_id or world.version.id}/osv/entries/{entry_id}/counterparty" + + +def _candidates_url(version_id: uuid.UUID) -> str: + return f"/consolidations/versions/{version_id}/counterparty-candidates" + + +async def _entry(session: AsyncSession, entry_id: uuid.UUID) -> OsvEntry: + stmt = select(OsvEntry).where(OsvEntry.id == entry_id).execution_options(populate_existing=True) + return (await session.execute(stmt)).scalar_one() + + +async def _aliases(session: AsyncSession) -> list[tuple[uuid.UUID, str, str]]: + stmt = select(LegalEntityAlias).order_by(LegalEntityAlias.alias).execution_options(populate_existing=True) + return [(a.legal_entity_id, a.alias, a.normalized_alias) for a in (await session.execute(stmt)).scalars()] + + +async def _events(session: AsyncSession, action: AuditAction) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id) + return list((await session.execute(stmt)).scalars()) + + +async def _summary(client: Any, world: World) -> dict[str, Any]: + response = await client.get(f"/consolidations/versions/{world.version.id}/osv/summary") + assert response.status_code == HTTP_OK, response.text + body: dict[str, Any] = response.json() + return body + + +async def _assert_kind_iff_id(session: AsyncSession) -> None: + """Инвариант v5.16: `counterparty_match_kind IS NULL` ⇔ `counterparty_id IS NULL` во всех строках.""" + broken = ( + await session.execute( + select(func.count()) + .select_from(OsvEntry) + .where((OsvEntry.counterparty_id.is_(None)) != (OsvEntry.counterparty_match_kind.is_(None))) + ) + ).scalar_one() + assert broken == 0 + + +async def _primary(session: AsyncSession) -> dict[uuid.UUID, tuple[str | None, str | None]]: + rows = await session.execute(select(OsvEntry.id, OsvEntry.counterparty_name, OsvEntry.counterparty_inn)) + return {row[0]: (row[1], row[2]) for row in rows} + + +# --- кандидаты ----------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_candidates_recommend_schastlivochnaya_first( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Сценарий «Рекомендуемая Счастливочная»: `scope=group` без `q`, `entry_id` — строка РБ «Пекарня Счастливочная + ООО».""" + world = await _world(seeded) + client = await auth_client(KA_RF) + + response = await client.get(_candidates_url(world.version.id), params={"entry_id": str(world.entries[("60", 1)])}) + + assert response.status_code == HTTP_OK, response.text + items = response.json() + assert items[0]["id"] == str(world.schastl.id) + assert (items[0]["recommended"], items[0]["counterparty_type"]) == (True, "GSK") + assert items[0]["inn"] == world.schastl.inn and items[0]["name"] == "ООО «Пекарня Счастливочная»" + assert items[0]["company_group"] == {"id": str(world.group.id), "name": "ГК Русский бисквит"} + assert items[0]["gsk"] == {"id": str(world.gsk_id), "name": "ГСК Бисквит"} and items[0]["gsz"] is None + by_id = {item["id"]: item for item in items} + chkf = by_id[str(world.chkf.id)] + assert (chkf["recommended"], chkf["counterparty_type"]) == (False, "GK") + assert chkf["gsz"] == {"id": str(world.gsz_id), "name": "ГСЗ Бисквит"} and chkf["gsk"] is None + assert (by_id[str(world.rb.id)]["recommended"], by_id[str(world.rb.id)]["counterparty_type"]) == (False, "GSK") + assert str(world.namesake.id) not in by_id # чужая ГК — только в scope=all + assert [item["name"] for item in items[1:]] == sorted(item["name"] for item in items[1:]) + + +@pytest.mark.integration +async def test_candidates_without_entry_have_no_recommendation( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Без `entry_id` и с `entry_id` строки непривязанного документа рекомендаций нет; порядок — по имени.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + + for params in ({}, {"entry_id": str(world.entries[("unbound-58", 1)])}, {"entry_id": str(uuid.uuid4())}): + response = await client.get(_candidates_url(world.version.id), params=params) + assert response.status_code == HTTP_OK, response.text + items = response.json() + assert {item["id"] for item in items} == {str(world.rb.id), str(world.schastl.id), str(world.chkf.id)} + assert not any(item["recommended"] for item in items) + assert [item["name"] for item in items] == sorted(item["name"] for item in items) + + +@pytest.mark.integration +@pytest.mark.parametrize("params", [{"scope": "all"}, {"scope": "all", "q": "Сч"}, {"scope": "all", "q": " Сч "}]) +async def test_candidates_all_requires_query_of_three_chars( + auth_client: AuthClientFactory, seeded: AsyncSession, params: dict[str, str] +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + response = await client.get(_candidates_url(world.version.id), params=params) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "QUERY_TOO_SHORT" + + +@pytest.mark.integration +async def test_candidates_all_finds_entity_of_other_branch( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """`scope=all` — весь справочник без policy: ЮЛ ГК Краснодарского филиала находится; признак `EXTERNAL`.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + + response = await client.get( + _candidates_url(world.version.id), + params={"scope": "all", "q": "счастлив", "entry_id": str(world.entries[("60", 1)])}, + ) + + assert response.status_code == HTTP_OK, response.text + by_id = {item["id"]: item for item in response.json()} + assert set(by_id) == {str(world.schastl.id), str(world.namesake.id)} + namesake = by_id[str(world.namesake.id)] + assert (namesake["counterparty_type"], namesake["recommended"]) == ("EXTERNAL", True) + assert namesake["company_group"]["name"] == "ГК Кубань" + assert (await client.get(_candidates_url(world.version.id), params={"scope": "bogus"})).status_code == 422 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_candidates_search_by_inn_prefix_alias_and_literal_wildcards( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + seeded.add(LegalEntityAlias(legal_entity_id=world.chkf.id, alias="Вологодская выпечка", + normalized_alias="вологодская выпечка")) # fmt: skip + await seeded.flush() + client = await auth_client(KA_RF) + + async def ids(**params: str) -> set[str]: + response = await client.get(_candidates_url(world.version.id), params=params) + assert response.status_code == HTTP_OK, response.text + return {item["id"] for item in response.json()} + + assert await ids(q=world.schastl.inn[:5]) == {str(world.schastl.id)} # префикс ИНН + assert await ids(q=world.schastl.inn[2:]) == set() # ИНН — только префикс + assert await ids(q="ВОЛОГОДСК") == {str(world.chkf.id)} # подстрока алиаса без регистра + assert await ids(q="бисквит") == {str(world.rb.id)} # подстрока имени без регистра + assert await ids(q="%") == set() and await ids(q="_") == set() # шаблоны LIKE экранированы + assert await ids(scope="all", q=f"{world.namesake.inn[:4]}") >= {str(world.namesake.id)} + + +@pytest.mark.integration +async def test_candidates_limit_keeps_recommended_first(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + for n in range(MAX_CANDIDATES + 5): + await make_legal_entity(seeded, name=f"АА Ранний {n:03d}", group=world.group) + client = await auth_client(KA_RF) + + response = await client.get(_candidates_url(world.version.id), params={"entry_id": str(world.entries[("60", 1)])}) + + items = response.json() + assert len(items) == MAX_CANDIDATES + assert items[0]["id"] == str(world.schastl.id) and items[0]["recommended"] is True + + +@pytest.mark.integration +async def test_candidates_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + curator = await auth_client(CURATOR) + foreign = await auth_client(FOREIGN) + admin = await auth_client("admin@armka.local") + + assert (await curator.get(_candidates_url(world.version.id))).status_code == HTTP_OK + hidden = await foreign.get(_candidates_url(world.version.id)) + missing = await foreign.get(_candidates_url(uuid.uuid4())) + assert hidden.status_code == missing.status_code == HTTP_NOT_FOUND + assert hidden.json() == missing.json() + assert (await admin.get(_candidates_url(world.version.id))).status_code == HTTP_FORBIDDEN + + +# --- ручное сопоставление ------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_mapping_from_slice_plan_propagates_and_creates_alias( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Сценарий «Сопоставление из плана среза» и «ИНН противоречит».""" + world = await _world(seeded) + client = await auth_client(KA_RF) + before_summary = await _summary(client, world) + primary_before = await _primary(seeded) + target = world.entries[("60", 1)] + + response = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.schastl.id)}) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["propagated"], body["alias_created"], body["alias_moved_from"]) == (2, True, None) + row = body["row"] + assert row["entry_ids"] == [str(target)] + assert (row["counterparty_id"], row["counterparty_type"], row["match_kind"], row["match_stored"]) == ( + str(world.schastl.id), "GSK", "MANUAL", True) # fmt: skip + assert row["counterparty_entity_name"] == "ООО «Пекарня Счастливочная»" + stored = await _entry(seeded, target) + assert (stored.counterparty_id, stored.counterparty_match_kind) == (world.schastl.id, CounterpartyMatchKind.MANUAL) + assert stored.modified_by is None and stored.modified_at is None and stored.is_modified is False + for key in (("62", 1), ("66", 1)): + entry = await _entry(seeded, world.entries[key]) + assert (entry.counterparty_id, entry.counterparty_match_kind) == ( + world.schastl.id, CounterpartyMatchKind.PROPAGATED), key # fmt: skip + for key in (("76", 1), ("60", 3), ("unbound-58", 1)): # ИНН противоречит; NU; документ не привязан + entry = await _entry(seeded, world.entries[key]) + assert (entry.counterparty_id, entry.counterparty_match_kind) == (None, None), key + chkf_row = await _entry(seeded, world.entries[("60", 2)]) + assert (chkf_row.counterparty_id, chkf_row.counterparty_match_kind) == (world.chkf.id, CounterpartyMatchKind.MANUAL) + assert await _aliases(seeded) == [(world.schastl.id, NAME, "пекарня счастливочная")] + alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() + assert alias.created_by == (await _user(seeded, KA_RF)).id and alias.created_at is not None + after_summary = await _summary(client, world) + assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) + assert after_summary["unmapped_total"] == 1 # строка 76 с чужим ИНН + assert await _primary(seeded) == primary_before # counterparty_name/inn не тронуты + await _assert_kind_iff_id(seeded) + + +@pytest.mark.integration +async def test_mapping_writes_one_map_event_and_alias_event( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + target = world.entries[("60", 1)] + + response = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.schastl.id)}) + + assert response.status_code == HTTP_OK, response.text + user_id = (await _user(seeded, KA_RF)).id + (event,) = await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) + assert (event.section, event.object_type, event.object_id) == (AuditSection.CONSOLIDATION, "osv_entry", target) + assert (event.consolidation_version_id, event.user_id) == (world.version.id, user_id) + assert event.before_value == {"counterparty_id": None, "match_kind": None} + assert event.after_value == { + "counterparty_id": str(world.schastl.id), + "match_kind": "MANUAL", + "propagated_entry_ids": sorted([str(world.entries[("62", 1)]), str(world.entries[("66", 1)])]), + "alias_created": True, + "alias_moved_from": None, + } + (alias_event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) + alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() + assert (alias_event.section, alias_event.object_type, alias_event.object_id) == ( + AuditSection.DIRECTORIES, "legal_entity_alias", alias.id) # fmt: skip + assert alias_event.before_value is None + assert alias_event.after_value == { + "legal_entity_id": str(world.schastl.id), "alias": NAME, "normalized_alias": "пекарня счастливочная", + } # fmt: skip + assert alias_event.consolidation_version_id == world.version.id and alias_event.user_id == user_id + + +@pytest.mark.integration +async def test_alias_taken_by_other_entity_is_conflict_and_changes_nothing( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Сценарий «Алиас занят»: 409 `ALIAS_TAKEN` с `owners`; строки, алиасы и журнал не изменились.""" + world = await _world(seeded) + firm = await make_legal_entity(seeded, name="Фирма Эф") + seeded.add(LegalEntityAlias(legal_entity_id=firm.id, alias="«Пекарня Счастливочная»", + normalized_alias="пекарня счастливочная")) # fmt: skip + await seeded.flush() + aliases_before = await _aliases(seeded) + client = await auth_client(KA_RF) + + response = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert response.status_code == HTTP_CONFLICT, response.text + detail = response.json()["detail"] + assert detail["code"] == "ALIAS_TAKEN" + assert detail["owners"] == [{"legal_entity_id": str(firm.id), "name": "Фирма Эф"}] + assert await _aliases(seeded) == aliases_before + for key in (("60", 1), ("62", 1), ("66", 1)): + entry = await _entry(seeded, world.entries[key]) + assert (entry.counterparty_id, entry.counterparty_match_kind) == (None, None) + assert await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) == [] + assert await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) == [] + + +@pytest.mark.integration +async def test_move_alias_deletes_foreign_alias_and_creates_own( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Сценарий «Перенос алиаса»: два `LEGAL_ENTITY_CHANGE` и один `OSV_COUNTERPARTY_MAP`; `DIRECTORY_DELETE` не нужен + (у `ka_rf` его нет — проверено seed-ом ролей).""" + world = await _world(seeded) + firm = await make_legal_entity(seeded, name="Фирма Эф") + old = LegalEntityAlias(legal_entity_id=firm.id, alias="«Пекарня Счастливочная»", + normalized_alias="пекарня счастливочная") # fmt: skip + seeded.add(old) + await seeded.flush() + old_id = old.id + client = await auth_client(KA_RF) + + response = await client.post( + _map_url(world, world.entries[("60", 1)]), + json={"legal_entity_id": str(world.schastl.id), "move_alias": True}, + ) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["alias_created"], body["alias_moved_from"], body["propagated"]) == (True, str(firm.id), 2) + assert await _aliases(seeded) == [(world.schastl.id, NAME, "пекарня счастливочная")] + removed, created = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) + assert (removed.object_type, removed.object_id, removed.after_value) == ("legal_entity_alias", old_id, None) + assert removed.before_value == { + "legal_entity_id": str(firm.id), "alias": "«Пекарня Счастливочная»", + "normalized_alias": "пекарня счастливочная", + } # fmt: skip + assert created.before_value is None and created.after_value["legal_entity_id"] == str(world.schastl.id) + (event,) = await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) + assert event.after_value["alias_moved_from"] == str(firm.id) + + +@pytest.mark.integration +async def test_inn_equal_after_whitespace_is_propagated(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Автоприменение: ИНН строки пустой или равный ИНН выбранного (без пробелов) — получает, иной — нет.""" + world = await _world(seeded) + spaced = f" {world.schastl.inn[:4]} {world.schastl.inn[4:]} " + extra = await make_confirmed_osv( + seeded, entity=world.rb, account="68", year=YEAR, quarter=Q3, is_cumulative=True, + rows=[{"name": NAME, "inn": spaced}, {"name": NAME, "inn": " "}, {"name": NAME, "inn": ALIEN_INN}], + ) # fmt: skip + await _bind(seeded, world.version, extra) + same_inn, blank_inn, alien_inn = await _entries_of(seeded, extra) + client = await auth_client(KA_RF) + + response = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["propagated"] == 4 # noqa: PLR2004 - 62, 66 и две строки 68 + assert (await _entry(seeded, same_inn)).counterparty_match_kind == CounterpartyMatchKind.PROPAGATED + assert (await _entry(seeded, blank_inn)).counterparty_match_kind == CounterpartyMatchKind.PROPAGATED + assert (await _entry(seeded, alien_inn)).counterparty_id is None + assert (await _entry(seeded, same_inn)).counterparty_inn == spaced + + +@pytest.mark.integration +async def test_remap_linked_row_keeps_previous_in_before(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Перевязка уже связанной строки: ошибочно к ЧКФ, затем к Счастливочной (алиас переносится с ЧКФ).""" + world = await _world(seeded) + client = await auth_client(KA_RF) + target = world.entries[("60", 1)] + first = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.chkf.id)}) + assert first.status_code == HTTP_OK, first.text + assert first.json()["row"]["counterparty_type"] == "GK" + + taken = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.schastl.id)}) + assert taken.status_code == HTTP_CONFLICT and taken.json()["detail"]["code"] == "ALIAS_TAKEN" + second = await client.post( + _map_url(world, target), json={"legal_entity_id": str(world.schastl.id), "move_alias": True} + ) + + assert second.status_code == HTTP_OK, second.text + body = second.json() + assert (body["propagated"], body["alias_created"], body["alias_moved_from"]) == (0, True, str(world.chkf.id)) + assert body["row"]["counterparty_id"] == str(world.schastl.id) and body["row"]["match_kind"] == "MANUAL" + _, remap = await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) + assert remap.before_value == {"counterparty_id": str(world.chkf.id), "match_kind": "MANUAL"} + assert remap.after_value["propagated_entry_ids"] == [] + # ранее автоприменённые строки остаются за ЧКФ: автоприменение трогает только пустые `counterparty_id` + assert (await _entry(seeded, world.entries[("62", 1)])).counterparty_id == world.chkf.id + await _assert_kind_iff_id(seeded) + + +@pytest.mark.integration +async def test_second_mapping_to_same_entity_reuses_alias(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Алиас у выбранного ЮЛ уже есть → `alias_created = false`, новых `LEGAL_ENTITY_CHANGE` нет.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + first = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + assert first.status_code == HTTP_OK, first.text + + second = await client.post( + _map_url(world, world.entries[("76", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert second.status_code == HTTP_OK, second.text + assert (second.json()["alias_created"], second.json()["propagated"]) == (False, 0) + assert len(await _aliases(seeded)) == 1 + assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == 1 + assert (await _summary(client, world))["unmapped_total"] == 0 + + +@pytest.mark.integration +async def test_name_of_legal_forms_only_links_without_alias( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Review Focus 3: имя из одних ОПФ — `normalize_alias` пуст: алиас не создаётся, строка связывается, + автоприменение по `strip()` имени работает (и не цепляет другие имена, нормализующиеся в пустое).""" + world = await _world(seeded) + document = await make_confirmed_osv( + seeded, entity=world.rb, account="68", year=YEAR, quarter=Q3, is_cumulative=True, + rows=[{"name": "ООО"}, {"name": " ООО "}, {"name": "ООО."}, {"name": "АО"}], + ) # fmt: skip + await _bind(seeded, world.version, document) + target, same, punct, other = await _entries_of(seeded, document) + client = await auth_client(KA_RF) + + response = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.chkf.id)}) + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["alias_created"], body["alias_moved_from"], body["propagated"]) == (False, None, 1) + assert body["row"]["match_kind"] == "MANUAL" and body["row"]["counterparty_id"] == str(world.chkf.id) + assert (await _entry(seeded, same)).counterparty_match_kind == CounterpartyMatchKind.PROPAGATED + assert (await _entry(seeded, punct)).counterparty_id is None + assert (await _entry(seeded, other)).counterparty_id is None + assert await _aliases(seeded) == [] + assert await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) == [] + (event,) = await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) + assert event.after_value["alias_created"] is False and event.after_value["propagated_entry_ids"] == [str(same)] + + +@pytest.mark.integration +async def test_mapping_in_one_version_is_visible_in_other(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Review Focus 1: консолидации ГК за Q2 и Q3; квартальная ОСВ Q1 привязана к обеим. Сопоставление в версии Q2 + меняет `osv_entry` — в версии Q3 строка видна с `MANUAL`; строка того же имени из ОСВ Q3 (привязана только к + версии Q3) автоприменением версии Q2 не затронута.""" + world = await _world(seeded) + owner = await _user(seeded, KA_RF) + _, q2 = await make_consolidation( + seeded, group=world.group, year=YEAR, quarter=Q2, members=[world.rb, world.schastl], initiator=owner, + status=ConsolidationStatus.DATA_CORRECTION, + ) # fmt: skip + await make_lock(seeded, version=q2, user=owner) + quarterly = { + quarter: await make_confirmed_osv( + seeded, + entity=world.rb, + account="51", + year=YEAR, + quarter=quarter, + is_cumulative=False, + rows=[{"name": "Банк Альфа"}] if quarter is not Q2 else [{"name": "Прочий"}], + ) # fmt: skip + for quarter in (Q1, Q2, Q3) + } + await _bind(seeded, q2, quarterly[Q1], quarterly[Q2]) + await _bind(seeded, world.version, quarterly[Q1], quarterly[Q2], quarterly[Q3]) + (shared_entry,) = await _entries_of(seeded, quarterly[Q1]) + (q3_entry,) = await _entries_of(seeded, quarterly[Q3]) + client = await auth_client(KA_RF) + + response = await client.post(_map_url(world, shared_entry, q2.id), json={"legal_entity_id": str(world.chkf.id)}) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["propagated"] == 0 + assert (await _entry(seeded, q3_entry)).counterparty_id is None + page = await client.get(f"/consolidations/versions/{world.version.id}/osv", params={"q": "Банк Альфа"}) + (row,) = page.json()["items"] + assert set(row["entry_ids"]) == {str(shared_entry), str(q3_entry)} + assert (row["counterparty_id"], row["match_kind"], row["match_stored"]) == (str(world.chkf.id), "MANUAL", True) + (event,) = await _events(seeded, AuditAction.OSV_COUNTERPARTY_MAP) + assert event.consolidation_version_id == q2.id + + +@pytest.mark.integration +async def test_mapping_of_entry_outside_summary_returns_null_row( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Строка привязанного документа пары с пробелом в своде не участвует: сопоставление пишется, `row = null`.""" + world = await _world(seeded) + q1 = await make_confirmed_osv( + seeded, entity=world.rb, account="51", year=YEAR, quarter=Q1, is_cumulative=False, rows=[{"name": "Банк Бета"}] + ) + await _bind(seeded, world.version, q1) # за Q2 и Q3 по счёту 51 ничего нет — пробел + (entry_id,) = await _entries_of(seeded, q1) + client = await auth_client(KA_RF) + + response = await client.post(_map_url(world, entry_id), json={"legal_entity_id": str(world.chkf.id)}) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["row"] is None + assert (await _entry(seeded, entry_id)).counterparty_match_kind == CounterpartyMatchKind.MANUAL + + +@pytest.mark.integration +async def test_entry_not_in_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Строка непривязанного документа, строка NU и несуществующая — один 404 (`ENTRY_NOT_FOUND`).""" + world = await _world(seeded) + client = await auth_client(KA_RF) + body = {"legal_entity_id": str(world.schastl.id)} + + responses = [ + await client.post(_map_url(world, entry_id), json=body) + for entry_id in (world.entries[("unbound-58", 1)], world.entries[("60", 3)], uuid.uuid4()) + ] + + assert {response.status_code for response in responses} == {HTTP_NOT_FOUND} + assert responses[0].json() == responses[1].json() == responses[2].json() + assert responses[0].json()["detail"]["code"] == "NOT_FOUND" + assert "строка" in responses[0].json()["detail"]["message"].lower() + assert (await _entry(seeded, world.entries[("unbound-58", 1)])).counterparty_id is None + + +@pytest.mark.integration +async def test_legal_entity_must_exist_but_is_not_filtered_by_policy( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + target = world.entries[("60", 1)] + + missing = await client.post(_map_url(world, target), json={"legal_entity_id": str(uuid.uuid4())}) + foreign = await client.post(_map_url(world, target), json={"legal_entity_id": str(world.namesake.id)}) + + assert missing.status_code == HTTP_NOT_FOUND + assert missing.json()["detail"] == {"code": "NOT_FOUND", "message": "ЮЛ не найдено"} + assert foreign.status_code == HTTP_OK, foreign.text # ЮЛ другого филиала — без policy + assert foreign.json()["row"]["counterparty_type"] == "EXTERNAL" + + +@pytest.mark.integration +async def test_mapping_requires_own_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Без блокировки — `LOCK_REQUIRED` даже для несуществующей строки (редактируемость проверяется раньше строки).""" + world = await _world(seeded, locked_by=None) + client = await auth_client(KA_RF) + + for entry_id in (world.entries[("60", 1)], uuid.uuid4()): + response = await client.post(_map_url(world, entry_id), json={"legal_entity_id": str(world.schastl.id)}) + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCK_REQUIRED" + assert (await _entry(seeded, world.entries[("60", 1)])).counterparty_id is None + + +@pytest.mark.integration +async def test_mapping_with_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, locked_by=RKA) + client = await auth_client(KA_RF) + + response = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "LOCKED_BY_OTHER" + + +@pytest.mark.integration +async def test_mapping_in_cancelled_version_is_not_editable( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded, status=ConsolidationStatus.CANCELLED, locked_by=None) + client = await auth_client(KA_RF) + + response = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + + +@pytest.mark.integration +async def test_mapping_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, office_name=KRASNODAR, locked_by=None) + body = {"legal_entity_id": str(world.schastl.id)} + rostov = await auth_client(KA_RF) + + hidden = await rostov.post(_map_url(world, world.entries[("60", 1)]), json=body) + missing = await rostov.post(_map_url(world, world.entries[("60", 1)], uuid.uuid4()), json=body) + assert hidden.status_code == missing.status_code == HTTP_NOT_FOUND + assert hidden.json() == missing.json() + for email in (CURATOR, EXPERT): + client = await auth_client(email) + assert (await client.post(_map_url(world, world.entries[("60", 1)]), json=body)).status_code == HTTP_FORBIDDEN + malformed = await rostov.post(_map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": "x"}) + assert malformed.status_code == HTTP_UNPROCESSABLE + + +# --- конкурентность ------------------------------------------------------------------------------------------------ + + +async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: + """Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки.""" + deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS + while asyncio.get_running_loop().time() < deadline: + async with maker() as session: + waiting = ( + await session.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock'" + ) + ) + ).scalar_one() + if waiting: + return + await asyncio.sleep(0.05) + raise AssertionError("запрос не встал в ожидание блокировки") + + +@pytest.mark.integration +async def test_concurrent_mappings_in_two_versions_serialize_on_shared_rows( + committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory +) -> None: + """Общий документ двух версий, имя из одних ОПФ (advisory-блокировки алиаса нет): сопоставление в версии Q2 + держит строки; сопоставление соседней строки в версии Q3 ждёт его commit и читает свежее состояние + (`before` = автоприменённое первым), а автоприменённое первым не перетирает.""" + async with committed_sessionmaker() as setup, setup.begin(): + world = await _world(setup) + owner = await _user(setup, KA_RF) + _, q2 = await make_consolidation( + setup, group=world.group, year=YEAR, quarter=Q2, members=[world.rb, world.schastl], initiator=owner, + status=ConsolidationStatus.DATA_CORRECTION, + ) # fmt: skip + await make_lock(setup, version=q2, user=owner) + shared = await make_confirmed_osv( + setup, entity=world.rb, account="51", year=YEAR, quarter=Q1, is_cumulative=False, + rows=[{"name": "ООО"}, {"name": "ООО"}], + ) # fmt: skip + await _bind(setup, q2, shared) + await _bind(setup, world.version, shared) + first_entry, second_entry = await _entries_of(setup, shared) + ids = (q2.id, world.version.id, world.chkf.id, world.schastl.id, owner.id) + q2_id, q3_id, chkf_id, schastl_id, owner_id = ids + client = await committed_client(KA_RF) + + async with committed_sessionmaker() as holder, holder.begin(): + audit = RequestAudit(service=AuditService(holder), user_id=owner_id, ip_address=None, user_agent=None) + service = CounterpartyMappingService(holder, audit, VisibilityScope(office_id=None)) + result = await service.map(q2_id, first_entry, MapCounterpartyIn(legal_entity_id=chkf_id)) + assert result.propagated == 1 + racing = asyncio.create_task( + client.post( + f"/consolidations/versions/{q3_id}/osv/entries/{second_entry}/counterparty", + json={"legal_entity_id": str(schastl_id)}, + ) + ) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not racing.done() + response = await asyncio.wait_for(racing, LOCK_WAIT_SECONDS) + + assert response.status_code == HTTP_OK, response.text + assert response.json()["propagated"] == 0 + async with committed_sessionmaker() as session: + first = await _entry(session, first_entry) + second = await _entry(session, second_entry) + assert (first.counterparty_id, first.counterparty_match_kind) == (chkf_id, CounterpartyMatchKind.MANUAL) + assert (second.counterparty_id, second.counterparty_match_kind) == (schastl_id, CounterpartyMatchKind.MANUAL) + events = await _events(session, AuditAction.OSV_COUNTERPARTY_MAP) + assert [event.consolidation_version_id for event in events] == [q2_id, q3_id] + assert events[1].before_value == {"counterparty_id": str(chkf_id), "match_kind": "PROPAGATED"} diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 0a4073f..1ba80c0 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -61,6 +61,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"}, + "/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -168,3 +170,20 @@ def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, A change = schema["paths"]["/api/v1/consolidations/versions/{version_id}/status"]["post"]["responses"] assert {"200", "404", "409", "422"} <= set(change) assert change["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard") + + +def test_counterparty_mapping_routes_declare_documented_responses(schema: dict[str, Any]) -> None: + """Кандидаты — список `CandidateOut`, 404/422; сопоставление — `MapCounterpartyOut` (строка может быть `null`), + 404/409/422.""" + candidates = schema["paths"]["/api/v1/consolidations/versions/{version_id}/counterparty-candidates"]["get"] + assert {"200", "404", "422"} <= set(candidates["responses"]) + assert candidates["responses"]["200"]["content"]["application/json"]["schema"]["items"]["$ref"].endswith( + "/CandidateOut" + ) + assert {"q", "scope", "entry_id"} <= {parameter["name"] for parameter in candidates["parameters"]} + path = "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty" + mapping = schema["paths"][path]["post"]["responses"] + assert {"200", "404", "409", "422"} <= set(mapping) + assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut") + row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"] + assert {"type": "null"} in row["anyOf"] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index a92e51e..0f94aa1 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -219,9 +219,9 @@ - `CounterpartyMappingService(session, audit: RequestAudit, scope).map(version_id, entry_id, data: MapCounterpartyIn) -> MapCounterpartyOut`: `lock_version` → видимость → `ensure_version_editable` → `entry_in_version` (404 `ENTRY_NOT_FOUND`) → ЮЛ (`session.get(LegalEntity)`, 404 `LEGAL_ENTITY_NOT_FOUND`) → `normalized` → владельцы ≠ target: `move_alias` false → 409 `ALIAS_TAKEN` (`owners: [{legal_entity_id, name}]`); true → delete каждого + `LEGAL_ENTITY_CHANGE` (`object_type = legal_entity_alias`, `before = {alias, legal_entity_id}`) → алиаса у target нет и `normalized` непуст → `LegalEntityAlias(alias = counterparty_name.strip(), normalized_alias, created_by)` + `LEGAL_ENTITY_CHANGE` → строке `counterparty_id`, `MANUAL` → `entries_same_name` с `counterparty_id IS NULL` и (`counterparty_inn` пуст или `normalize_inn == target.inn`) → `PROPAGATED` → `OSV_COUNTERPARTY_MAP` (`before = {counterparty_id, match_kind}`, `after = {counterparty_id, match_kind: MANUAL, propagated_entry_ids, alias_created, alias_moved_from}`, `consolidation_version_id`) → ответ с пересчитанной строкой (`_build` заново, строка по `entry_id ∈ entry_ids`). - Маршруты: `GET …/counterparty-candidates` (`CONSOLIDATION_VIEW`), `POST …/osv/entries/{entry_id}/counterparty` (`CONSOLIDATION_EDIT`, 200). -- [ ] **Step 1: Тесты.** Сценарии спеки «Кандидаты» (рекомендуемая Счастливочная, `scope=all` с коротким `q` → 422, ЮЛ другого филиала находится в `all`), «Ручное сопоставление» (сопоставление из плана среза с `propagated` по трём ОСВ, `ALIAS_TAKEN`, перенос алиаса с двумя `LEGAL_ENTITY_CHANGE`, ИНН противоречит, перевязка, `ENTRY_NOT_FOUND` для строки непривязанного документа, без блокировки → `LOCK_REQUIRED`, `CANCELLED` → `VERSION_NOT_EDITABLE`); Review Focus 1 — строка видна во второй версии с `MANUAL`; Review Focus 3 — имя `ООО` → алиас не создан, строка связана. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_counterparty_mapping.py tests/test_api_aliases.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): кандидаты и ручное сопоставление контрагента с алиасом и автоприменением`. +- [x] **Step 1: Тесты.** Сценарии спеки «Кандидаты» (рекомендуемая Счастливочная, `scope=all` с коротким `q` → 422, ЮЛ другого филиала находится в `all`), «Ручное сопоставление» (сопоставление из плана среза с `propagated` по трём ОСВ, `ALIAS_TAKEN`, перенос алиаса с двумя `LEGAL_ENTITY_CHANGE`, ИНН противоречит, перевязка, `ENTRY_NOT_FOUND` для строки непривязанного документа, без блокировки → `LOCK_REQUIRED`, `CANCELLED` → `VERSION_NOT_EDITABLE`); Review Focus 1 — строка видна во второй версии с `MANUAL`; Review Focus 3 — имя `ООО` → алиас не создан, строка связана. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_counterparty_mapping.py tests/test_api_aliases.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): кандидаты и ручное сопоставление контрагента с алиасом и автоприменением`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index bc8bfd4..e7458b1 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -246,3 +246,12 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 59 | 2026-10-06 | `account_sort_key` считает числовым сегмент только из ASCII-цифр (`[0-9]+`), остальные — текстом после числовых; `isdigit()` пропускал `"6²"`/`"①"`, на которых `int()` падает (T9.a) | код счёта приходит из файла; исключение роняло бы всю страницу свода и чек-лист (500) | при ошибке — экзотический код счёта сортируется как текст, а не по значению; падения нет | | 60 | 2026-10-06 | Идентификаторы кандидатов дорезолва `_build` собирает заранее: `CounterpartyIndex.resolve` (словари в памяти, без БД) по каждой строке без `counterparty_id`, плюс хранимые `counterparty_id`; один `SELECT legal_entity … WHERE id IN (…)` без policy; члены периметра берутся из `members` и в запрос не входят. Запросов к БД на `GET …/osv` — константа (8 SELECT на 5 400 исходных строк), без N+1; страница режется в памяти после `domain.build` и `apply_filter` | свод — чистая функция версии (D-1), срез и фильтры по `counterparty_type`/`unmapped` зависят от дорезолва, поэтому строить нужно весь свод | при ошибке — `IN` с очень большим числом контрагентов упрётся в лимит параметров asyncpg (32 767); число различных контрагентов версии на порядки меньше | | 61 | 2026-10-06 | `ConsolidatedOsvService` только читает: без блокировок и аудита, версия любого статуса (включая `CANCELLED`) читается; `documents` для `build` — привязанные документы `document_type = OSV` (`bound_documents`), `rows` — ровно результат `load_osv_rows` (`COUNTERPARTY`, `BU`/`SUM`): домен `group_type`/`data_type` не фильтрует (замечание ревью Task 9) | спека «Страница свода и сводка»; DECISION-5 | при ошибке — в свод попали бы строки `ACCOUNT`/`NU` и задвоили бы суммы | +| 62 | 2026-10-06 | `ENTRY_NOT_FOUND` — 404 с кодом `NOT_FOUND` и текстом «строка ОСВ не найдена в версии» (как `VERSION_NOT_FOUND`: `NotFoundError` несёт только сообщение). «Строка версии» — строка свода: `COUNTERPARTY` с `data_type ∈ {BU, SUM}` в ОСВ-документе, привязанном к версии (`entry_in_version`); строки `NU`/`ACCOUNT` привязанного документа дают тот же 404 | спека требует 404 для строки непривязанного документа; сопоставлять строку, которой нет в своде и которую маппер не резолвит (R-3 Среза 2c), незачем | клиент отличает «нет строки» от «нет версии» только по тексту | +| 63 | 2026-10-06 | Автоприменение — только строкам свода версии (`COUNTERPARTY`, `BU`/`SUM` привязанных ОСВ-документов); имя сравнивается как `strip()` Python ↔ SQL `btrim(counterparty_name, PY_WHITESPACE)` (полный набор `str.isspace`, включая NBSP — одно определение со склейкой D-2); пустое имя (NULL/пробелы) — автоприменения нет, связывается только сама строка | спека: «строкам ОСВ-документов, привязанных к версии, с тем же `counterparty_name.strip()`»; строки `NU` в свод не входят | строки `NU` того же контрагента остаются без `counterparty_id` (как и после маппинга) | +| 64 | 2026-10-06 | Перевязка уже связанной строки автоприменением не трогает строки, связанные прежде (в том числе `PROPAGATED` от прошлого ошибочного сопоставления) — только `counterparty_id IS NULL`; `before_value` хранит прежние `counterparty_id`/`match_kind` | спека: автоприменение — «пустым `counterparty_id`» | ошибочно распространённое сопоставление исправляется по строке (или повтором после отвязки — вне Среза 4) | +| 65 | 2026-10-06 | Порядок блокировок сопоставления: версия `FOR UPDATE` (guard) → advisory по нормализованному алиасу (`lock_alias_key`, только при непустом) → строки алиасов `FOR UPDATE` → строки `osv_entry` с тем же именем и сама строка `FOR NO KEY UPDATE ORDER BY id` (`lock_entries_same_name`, `populate_existing`). Гонка реальна только между сопоставлениями в РАЗНЫХ версиях с общими документами (OQ-123): маппер переписывает строки лишь своего файла в `PROCESSING`, удаление файла с `CONFIRMED` запрещено, автозамена только отвязывает, привязки версии меняются под её `FOR UPDATE`. Упорядоченная блокировка исключает взаимоблокировку (A связывает r1 и распространяет на r2, B — наоборот), а свежее чтение — перетирание чужого результата и устаревший `before` (тест на двух сессиях) | строки ОСВ общие для версий; без блокировки второе сопоставление перезаписало бы `PROPAGATED` первого и записало бы в аудит устаревшее `before` | при ошибке — взаимоблокировка (500) или потерянное сопоставление | +| 66 | 2026-10-06 | `lock_alias_key` берёт и `aliases.add` справочника (Среза 1): глобальная уникальность `normalized_alias` в БД не обеспечена (индекс — `(ЮЛ, normalized_alias)`), без общей блокировки добавление в справочнике ∥ сопоставление закрепили бы алиас за двумя ЮЛ. Нарушение индекса `(ЮЛ, normalized_alias)` при вставке алиаса в сопоставлении ловится под SAVEPOINT как «алиас у ЮЛ уже есть» (`alias_created = false`), сопоставление продолжается | последний рубеж на случай писателя вне этих путей; README «Гонки уникальных ограничений» обновлён | одна лишняя advisory-блокировка на добавление алиаса | +| 67 | 2026-10-06 | Алиас сопоставления хранит `counterparty_name.strip()`; `LEGAL_ENTITY_CHANGE` по созданному/удалённому алиасу — тот же payload, что в Срезе 1 (`legal_entity_id`, `alias`, `normalized_alias`; функция `aliases.alias_payload`), раздел `DIRECTORIES`, `object_type = legal_entity_alias`, `object_id` = алиас, и дополнительно `consolidation_version_id` версии | audit-trail: «как в Срезе 1»; ссылка на версию связывает событие справочника с причиной | нет (поле nullable, в Срезе 1 пустое) | +| 68 | 2026-10-06 | `alias_moved_from` (ответ и `after_value` `OSV_COUNTERPARTY_MAP`) — один `legal_entity_id`: первый владелец по `(created_at, id)`; при нескольких владельцах (возможны только из данных до общей блокировки) удаляются все их алиасы, каждый со своим `LEGAL_ENTITY_CHANGE`; `ALIAS_TAKEN.owners` — все владельцы в том же порядке | схема `MapCounterpartyOut.alias_moved_from: UUID \| None` (Task 3) | остальные прежние владельцы видны только в `LEGAL_ENTITY_CHANGE` | +| 69 | 2026-10-06 | `MapCounterpartyOut.row` — `ConsolidatedRowOut \| null`: строка привязанного документа, которая в свод не попадает (пара с пробелом, квартальный документ при нарастающем), сопоставляется, но строки свода для ответа нет — `row = null` | спека разрешает сопоставлять любую строку привязанного ОСВ-документа; выдумывать строку свода из одной исходной нельзя | фронт обрабатывает `null` (из UI такая строка не выбирается — её нет в своде) | +| 70 | 2026-10-06 | Кандидаты: `entry_id` не строки свода версии (чужой, непривязанный, несуществующий) или имя с пустой нормализацией — рекомендаций нет, не 404; `recommended` ищется по `CounterpartyIndex.by_normalized` (имена и алиасы всего справочника; загружается только при `entry_id`); `q` обрезается по краям (`scope=all`: пустой или короче 3 — 422 `QUERY_TOO_SHORT`; `max_length` 500); ИНН — префикс без пробелов внутри `q`; алиас — подстрока `legal_entity_alias.alias`; `scope=group` = ГК консолидации ∪ периметр версии; признак — `domain.classify` | D-11 говорит «если передан»; нормализация — функция Python, в SQL её нет, а индекс — тот же, что у резолва свода | лишняя загрузка справочника на запрос с `entry_id` (как у `GET …/osv`) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 9f3e17d..a269b23 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -48,7 +48,7 @@ все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`. - [x] 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` +- [x] 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`, -- 2.47.2 From 8e82c2c19f87b26541662e5a4f7407c5a71a6aef Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:04:53 +0400 Subject: [PATCH 18/27] =?UTF-8?q?feat(consolidation):=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=9E=D0=A1=D0=92=20=D0=B2=20xlsx=20=D1=81=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /consolidations/versions/{id}/osv/export: лист «Сводная ОСВ» (15 колонок D-14, суммы — числовые ячейки #,##0.00 из Decimal) и лист «Пробелы» по своду целиком; фильтры как у GET …/osv без пагинации; Content-Disposition с filename* (чистый ASCII); audit_event(EXPORT) с filters и rows. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../api/endpoints/consolidations/__init__.py | 3 +- .../api/endpoints/consolidations/export.py | 55 +++ api/src/services/consolidated_osv.py | 33 +- api/src/services/osv_export.py | 95 ++++++ api/tests/test_api_osv_export.py | 323 ++++++++++++++++++ api/tests/test_openapi_routes.py | 12 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/design.md | 6 + .../changes/slice-4-consolidation/tasks.md | 2 +- 9 files changed, 529 insertions(+), 6 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/export.py create mode 100644 api/src/services/osv_export.py create mode 100644 api/tests/test_api_osv_export.py diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 95f13c9..3521738 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import checklist, locks, mapping, members, osv, periods, status, versions +from src.api.endpoints.consolidations import checklist, export, locks, mapping, members, osv, periods, status, versions router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -12,4 +12,5 @@ router.include_router(status.router) router.include_router(members.router) router.include_router(checklist.router) router.include_router(osv.router) +router.include_router(export.router) router.include_router(mapping.router) diff --git a/api/src/api/endpoints/consolidations/export.py b/api/src/api/endpoints/consolidations/export.py new file mode 100644 index 0000000..a960f42 --- /dev/null +++ b/api/src/api/endpoints/consolidations/export.py @@ -0,0 +1,55 @@ +"""Экспорт сводной ОСВ версии в xlsx (spec consolidated-osv «Экспорт xlsx», audit-trail `EXPORT`; design D-14). + +Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у несуществующей; версия читается в любом +статусе. Файл строится в памяти; единственная запись в БД — `audit_event(EXPORT)` в транзакции запроса.""" + +import uuid +from typing import Annotated, Any +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Query, Response, status + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import OsvFilters + +router = APIRouter(prefix="/consolidations/versions") + +XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: { + "description": "файл xlsx: листы «Сводная ОСВ» и «Пробелы»", + "content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}, + }, + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}, +} + + +def content_disposition(filename: str) -> str: + """Заголовок вложения: ASCII-имя по умолчанию и `filename*` (RFC 5987) с полным percent-кодированием имени — + пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, заголовок остаётся чистым ASCII.""" + return f"attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''{quote(filename, safe='')}" + + +@router.get( + "/{version_id}/osv/export", + response_class=Response, + dependencies=VIEW, + responses=RESPONSES, + summary="Экспорт сводной ОСВ версии в xlsx", +) +async def export_osv( + version_id: uuid.UUID, + service: ConsolidatedOsvServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + filters: Annotated[OsvFilters, Query()], +) -> Response: + """Файл `svod-osv_<ГК>_<год>-<квартал>_v.xlsx`: строки свода после фильтров (как у `GET …/osv`, без + пагинации, в том же порядке) и лист пробелов по своду целиком. Пишет `audit_event(EXPORT)`.""" + content, filename = await service.export(version_id, filters, audit) + return Response( + content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": content_disposition(filename)} + ) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 65b213d..42a1b7e 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -18,13 +18,14 @@ from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.audit import RequestAudit from src.common.exceptions import DomainValidationError, NotFoundError from src.domain import consolidated_osv as domain from src.domain.aliases import normalize_alias from src.domain.policy import VisibilityScope from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup -from src.models.enums import DocumentType, RelatedGroupKind +from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind from src.models.files import Document from src.models.osv import OsvEntry from src.repositories import consolidations as repo @@ -40,6 +41,7 @@ from src.schemas.consolidations import ( RefOut, ) from src.services.consolidation_guard import VERSION_NOT_FOUND +from src.services.osv_export import build_workbook __all__ = ["MAX_CANDIDATES", "MIN_QUERY_ALL", "CandidateScope", "ConsolidatedOsvService", "domain_filter", "row_out"] @@ -168,6 +170,35 @@ class ConsolidatedOsvService: items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset ) + async def export(self, version_id: uuid.UUID, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str]: + """xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в + порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v.xlsx` (без + percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" + consolidated, version, consolidation = await self._build(version_id) + rows = domain.apply_filter(consolidated.rows, domain_filter(f)) + content = build_workbook(consolidated, rows, entity_names={}) + group_name = ( + await self._session.execute( + select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) + ) + ).scalar_one() + await audit.record( + action=AuditAction.EXPORT, + section=AuditSection.EXPORT, + object_type="consolidation_version", + object_id=version.id, + consolidation_version_id=version.id, + after_value={ + "report": "consolidated_osv", + "filters": f.model_dump(mode="json", exclude_none=True), + "rows": len(rows), + }, + ) + filename = ( + f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx" + ) + return content, filename + async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" consolidated, _, _ = await self._build(version_id) diff --git a/api/src/services/osv_export.py b/api/src/services/osv_export.py new file mode 100644 index 0000000..e530e68 --- /dev/null +++ b/api/src/services/osv_export.py @@ -0,0 +1,95 @@ +"""Книга xlsx сводной ОСВ (spec consolidated-osv «Экспорт xlsx»; design D-14): чистая функция без БД. + +Лист «Сводная ОСВ» — строки свода в переданном порядке (порядок API, D-16), шесть сумм — числовые ячейки +`#,##0.00` в рублях без деления. В ячейки пишется `Decimal` как есть: `float` в нашем коде не используется; +`openpyxl` сам сериализует число в XML (формат xlsx хранит числа как IEEE-754 double, поэтому значения свыше +~10^15 теряют младшие разряды — для сумм ОСВ в рублях запас на три порядка). Лист «Пробелы» — пробелы свода +целиком (`Consolidated.gaps`), фильтры строк на него не влияют; без пробелов — одна ячейка «пробелов нет». +""" + +import uuid +from collections.abc import Mapping, Sequence +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +from src.domain.consolidated_osv import Consolidated, ConsolidatedRow + +__all__ = ["GAPS_SHEET", "MAIN_SHEET", "MONEY_FORMAT", "NO_GAPS", "build_workbook"] + +MAIN_SHEET = "Сводная ОСВ" +GAPS_SHEET = "Пробелы" +MONEY_FORMAT = "#,##0.00" +NO_GAPS = "пробелов нет" +MAIN_HEADER = ( + "Компания ГК", "ИНН компании", "Счёт", "Период", "Контрагент", "ИНН контрагента", "Сальдо начало Дт", + "Сальдо начало Кт", "Обороты Дт", "Обороты Кт", "Сальдо конец Дт", "Сальдо конец Кт", "Тип контрагента", + "Сопоставлено с", "Способ сопоставления", +) # fmt: skip +GAPS_HEADER = ("Компания ГК", "Счёт", "Нет ОСВ за кварталы") +FIRST_MONEY_COLUMN, LAST_MONEY_COLUMN = 7, 12 + + +def _widths(sheet: Worksheet, widths: Sequence[int]) -> None: + for index, width in enumerate(widths, start=1): + sheet.column_dimensions[get_column_letter(index)].width = width + + +def _main_sheet(sheet: Worksheet, rows: Sequence[ConsolidatedRow], entity_names: Mapping[uuid.UUID, str]) -> None: + sheet.title = MAIN_SHEET + sheet.append(MAIN_HEADER) + for row in rows: + sheet.append( + [ + entity_names.get(row.legal_entity_id, row.legal_entity_name), + row.legal_entity_inn, + row.account_code, + row.period, + row.counterparty_name, + row.counterparty_inn, + row.opening_debit, + row.opening_credit, + row.turnover_debit, + row.turnover_credit, + row.closing_debit, + row.closing_credit, + row.counterparty_type.value, + row.counterparty_entity_name, + None if row.match_kind is None else row.match_kind.value, + ] + ) + for cell in sheet[sheet.max_row][FIRST_MONEY_COLUMN - 1 : LAST_MONEY_COLUMN]: + cell.number_format = MONEY_FORMAT + sheet.freeze_panes = "A2" + _widths(sheet, (32, 14, 8, 10, 36, 14, 16, 16, 16, 16, 16, 16, 12, 32, 14)) + + +def _gaps_sheet(sheet: Worksheet, consolidated: Consolidated, entity_names: Mapping[uuid.UUID, str]) -> None: + sheet.title = GAPS_SHEET + if not consolidated.gaps: + sheet.append([NO_GAPS]) + return + sheet.append(GAPS_HEADER) + for gap in consolidated.gaps: + quarters = ", ".join(quarter.value for quarter in gap.missing_quarters) + sheet.append([entity_names.get(gap.legal_entity_id, gap.legal_entity_name), gap.account_code, quarters]) + _widths(sheet, (32, 8, 24)) + + +def build_workbook( + consolidated: Consolidated, rows: Sequence[ConsolidatedRow], *, entity_names: Mapping[uuid.UUID, str] +) -> bytes: + """Книга из двух листов: `rows` (уже отфильтрованные строки свода) и пробелы `consolidated.gaps`. + + `entity_names` — отображаемые имена ЮЛ по идентификатору (переопределяют имена из строк и пробелов; нет в + словаре — имя из самой строки/пробела). Только память: `BytesIO`, S3 и БД не используются.""" + workbook = Workbook() + main = workbook.active + assert main is not None # новая книга всегда с активным листом + _main_sheet(main, rows, entity_names) + _gaps_sheet(workbook.create_sheet(), consolidated, entity_names) + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() diff --git a/api/tests/test_api_osv_export.py b/api/tests/test_api_osv_export.py new file mode 100644 index 0000000..c0de420 --- /dev/null +++ b/api/tests/test_api_osv_export.py @@ -0,0 +1,323 @@ +"""Экспорт сводной ОСВ в xlsx (spec consolidated-osv «Экспорт xlsx», audit-trail EXPORT; design D-14): файл читается +обратно `openpyxl`, суммы — числа `#,##0.00` без `float` в нашем коде, лист «Пробелы», фильтры как у `GET …/osv`, +заголовок `Content-Disposition`, аудит, права и видимость, никаких записей кроме `audit_event`.""" + +import io +import uuid +from decimal import Decimal +from typing import Any +from urllib.parse import unquote + +import pytest +from openpyxl import load_workbook +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, CounterpartyMatchKind, PeriodQuarter +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +KA_RF = "ka_rf@armka.local" +ROSTOV = "Ростовский региональный филиал" +YEAR = 2024 +Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 +D = Decimal +MAIN, GAPS = "Сводная ОСВ", "Пробелы" +HEADER = [ + "Компания ГК", "ИНН компании", "Счёт", "Период", "Контрагент", "ИНН контрагента", "Сальдо начало Дт", + "Сальдо начало Кт", "Обороты Дт", "Обороты Кт", "Сальдо конец Дт", "Сальдо конец Кт", "Тип контрагента", + "Сопоставлено с", "Способ сопоставления", +] # fmt: skip +GAPS_HEADER = ["Компания ГК", "Счёт", "Нет ОСВ за кварталы"] +ROWS_TOTAL = 3 + + +class World: + """Версия Q3 2024, периметр {А, Б}. «А» по счёту 60 — Q1..Q3 (3 строки свода: «ООО Б» ГСК, «Неизвестный» + без сопоставления, «ООО Внешний» — имя найдётся дорезолвом); «Б» по счёту 60 — Q1 и Q3 (пробел Q2).""" + + version: ConsolidationVersion + group: CompanyGroup + a: LegalEntity + b: LegalEntity + external: LegalEntity + + +async def _world( + session: AsyncSession, *, group_name: str = "ГК Русский бисквит", gap: bool = True, + status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION, +) -> World: # fmt: skip + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + world = World() + world.group = await make_company_group(session, name=group_name, office=office) + world.a = await make_legal_entity(session, name="ООО А", group=world.group) + world.b = await make_legal_entity(session, name="ООО Б", group=world.group) + world.external = await make_legal_entity(session, name="ООО Внешний") + user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, world.version = await make_consolidation( + session, group=world.group, year=YEAR, quarter=Q3, members=[world.a, world.b], initiator=user, status=status + ) + documents = [] + for quarter in (Q1, Q2, Q3): + step = {Q1: 0, Q2: 10, Q3: 20}[quarter] + amounts = { + "opening_debit": D(100 + step) + D("0.05"), + "turnover_debit": D("10.10"), + "closing_credit": D("7.07"), + } + documents.append( + await make_confirmed_osv( + session, entity=world.a, account="60", year=YEAR, quarter=quarter, is_cumulative=False, + rows=[ + {"name": "ООО Б", "inn": world.b.inn, "counterparty_id": world.b.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, **amounts}, + {"name": "Неизвестный контрагент", **amounts}, + {"name": "ООО Внешний", **amounts}, + ], + ) + ) # fmt: skip + for quarter in (Q1, Q3) if gap else (Q1, Q2, Q3): + documents.append( + await make_confirmed_osv( + session, entity=world.b, account="60", year=YEAR, quarter=quarter, is_cumulative=False, + rows=[ + {"name": "ООО А", "counterparty_id": world.a.id, + "counterparty_match_kind": CounterpartyMatchKind.MANUAL, "turnover_credit": D("5.00")}, + ], + ) + ) # fmt: skip + for document in documents: + session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) + await session.flush() + return world + + +def _url(world: World, query: str = "") -> str: + return f"/consolidations/versions/{world.version.id}/osv/export?{query}" + + +def _book(content: bytes) -> Any: + return load_workbook(io.BytesIO(content)) + + +async def _export_events(session: AsyncSession) -> list[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT).order_by(AuditEvent.occurred_at) + return list((await session.execute(stmt)).scalars()) + + +@pytest.mark.integration +async def test_export_reads_back_with_amounts_equal_to_api( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + response = await client.get(_url(world)) + + assert response.status_code == HTTP_OK, response.text + assert response.headers["content-type"] == XLSX + book = _book(response.content) + assert book.sheetnames == [MAIN, GAPS] + sheet = book[MAIN] + rows = list(sheet.iter_rows(values_only=True)) + assert list(rows[0]) == HEADER + assert len(rows) == 1 + ROWS_TOTAL # заголовок и 3 строки: «Б» без строк (пробел) + api_items = (await client.get(f"/consolidations/versions/{world.version.id}/osv")).json()["items"] + assert len(api_items) == ROWS_TOTAL + money = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") + for item, row in zip(api_items, rows[1:], strict=True): + assert row[:6] == (item["legal_entity_name"], item["legal_entity_inn"], item["account_code"], item["period"], + item["counterparty_name"], item["counterparty_inn"]) # fmt: skip + assert [Decimal(str(value)) for value in row[6:12]] == [Decimal(item[key]) for key in money] + assert row[12:] == (item["counterparty_type"], item["counterparty_entity_name"], item["match_kind"]) + assert sheet.cell(2, 7).value == pytest.approx(100.05) # 100 + 0.05 (Q1): число, не строка + for cell in sheet[2][6:12]: + assert cell.number_format == "#,##0.00" and cell.data_type == "n" + + +@pytest.mark.integration +async def test_gaps_sheet_lists_missing_quarters(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + book = _book((await client.get(_url(world))).content) + + assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [GAPS_HEADER, ["ООО Б", "60", "Q2"]] + events = await _export_events(seeded) + assert len(events) == 1 + event = events[0] + assert (event.section, event.object_type, event.object_id) == (AuditSection.EXPORT, "consolidation_version", + world.version.id) # fmt: skip + assert event.consolidation_version_id == world.version.id + assert event.after_value == {"report": "consolidated_osv", "filters": {}, "rows": ROWS_TOTAL} + assert event.before_value is None and event.user_id is not None + + +@pytest.mark.integration +async def test_gap_quarters_joined_and_no_gaps_cell(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, gap=False) + client = await auth_client(KA_RF) + + book = _book((await client.get(_url(world))).content) + + assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [["пробелов нет"]] + assert len(list(book[MAIN].iter_rows())) == 1 + ROWS_TOTAL + 1 # «Б» теперь даёт строку + + +@pytest.mark.integration +async def test_unmapped_filter_after_reresolve_gives_header_only_and_filled_gaps( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Review Focus 5: у всех строк с `counterparty_id IS NULL` имя находится дорезолвом — несопоставленных нет.""" + world = await _world(seeded) + for entry in ( + await seeded.execute(select(OsvEntry).where(OsvEntry.counterparty_name == "Неизвестный контрагент")) + ).scalars(): + entry.counterparty_name = "ООО Внешний" + await seeded.flush() + client = await auth_client(KA_RF) + assert (await client.get(f"/consolidations/versions/{world.version.id}/osv?unmapped=true")).json()["total"] == 0 + + response = await client.get(_url(world, "unmapped=true")) + + assert response.status_code == HTTP_OK + book = _book(response.content) + assert [list(row) for row in book[MAIN].iter_rows(values_only=True)] == [HEADER] + assert [list(row) for row in book[GAPS].iter_rows(values_only=True)] == [GAPS_HEADER, ["ООО Б", "60", "Q2"]] + (event,) = await _export_events(seeded) + assert event.after_value == {"report": "consolidated_osv", "filters": {"unmapped": True}, "rows": 0} + + +FILTERS = [ + "", + "unmapped=true", + "unmapped=false", + "counterparty_type=GSK", + "counterparty_type=EXTERNAL&account_root=60", + "match_kind=INN&match_kind=MANUAL", + "match_kind=NAME", + "q=%D0%B2%D0%BD%D0%B5%D1%88", + "account_root=62", +] + + +@pytest.mark.integration +@pytest.mark.parametrize("query", FILTERS) +async def test_filters_match_page_endpoint_rows_and_order( + auth_client: AuthClientFactory, seeded: AsyncSession, query: str +) -> None: + world = await _world(seeded, gap=False) + client = await auth_client(KA_RF) + page = (await client.get(f"/consolidations/versions/{world.version.id}/osv?{query}&limit=2000")).json() + + book = _book((await client.get(_url(world, query))).content) + + exported = list(book[MAIN].iter_rows(min_row=2, values_only=True)) + assert [(row[0], row[2], row[4], row[12]) for row in exported] == [ + (item["legal_entity_name"], item["account_code"], item["counterparty_name"], item["counterparty_type"]) + for item in page["items"] + ] + (*_, event) = await _export_events(seeded) + assert event.after_value["rows"] == page["total"] == len(exported) + + +@pytest.mark.integration +async def test_filters_are_audited_as_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + query = f"legal_entity_id={world.a.id}&counterparty_type=GSK&match_kind=INN&match_kind=NAME&q=%D0%B1" + + assert (await client.get(_url(world, query))).status_code == HTTP_OK + + (event,) = await _export_events(seeded) + assert event.after_value["filters"] == { + "legal_entity_id": str(world.a.id), "counterparty_type": "GSK", "match_kind": ["INN", "NAME"], "q": "б", + } # fmt: skip + + +@pytest.mark.integration +@pytest.mark.parametrize("group_name", ["ГК Русский бисквит", 'ГК "Кавычки" / слэш; a=b\r\nX-Inj: 1', "ГК Ёлка ё'ё"]) +async def test_content_disposition_is_ascii_and_cannot_be_broken( + auth_client: AuthClientFactory, seeded: AsyncSession, group_name: str +) -> None: + world = await _world(seeded, group_name=group_name) + client = await auth_client(KA_RF) + + response = await client.get(_url(world)) + + assert response.status_code == HTTP_OK + header = response.headers["content-disposition"] + header.encode("ascii") + assert header.startswith("attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''svod-osv_") + encoded = header.split("filename*=UTF-8''", 1)[1] + assert encoded.endswith("_2024-Q3_v1.xlsx") + assert all(ch.isalnum() or ch in "%_.-~" for ch in encoded), encoded # пробел, кавычки, `;`, `/`, `'` экранированы + assert len(response.headers.get_list("content-disposition")) == 1 and "x-inj" not in response.headers + + +@pytest.mark.integration +async def test_filename_decodes_to_group_year_quarter_version( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + header = (await client.get(_url(world))).headers["content-disposition"] + + assert unquote(header.split("filename*=UTF-8''", 1)[1]) == "svod-osv_ГК Русский бисквит_2024-Q3_v1.xlsx" + + +@pytest.mark.integration +async def test_cancelled_version_is_exportable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded, status=ConsolidationStatus.CANCELLED) + client = await auth_client(KA_RF) + + response = await client.get(_url(world)) + + assert response.status_code == HTTP_OK + assert len(list(_book(response.content)[MAIN].iter_rows())) == 1 + ROWS_TOTAL + + +@pytest.mark.integration +async def test_rights_and_visibility(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + curator = await auth_client("curator_go@armka.local") + expert = await auth_client("expert@armka.local") + foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал + admin = await auth_client("admin@armka.local") # права CONSOLIDATION_VIEW у роли нет + + assert (await curator.get(_url(world))).status_code == HTTP_OK + assert (await expert.get(_url(world))).status_code == HTTP_OK + hidden = await foreign.get(_url(world)) + unknown = await foreign.get(f"/consolidations/versions/{uuid.uuid4()}/osv/export") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (await admin.get(_url(world))).status_code == HTTP_FORBIDDEN + events = await _export_events(seeded) + assert len(events) == 2 # noqa: PLR2004 - curator и expert; отказы и 404 не аудируются + + +@pytest.mark.integration +async def test_export_writes_only_the_audit_event(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Дорезолв не пишется в `osv_entry`, свод не хранится: меняется только число `audit_event`.""" + world = await _world(seeded) + client = await auth_client(KA_RF) + before = (await seeded.execute(select(OsvEntry.id, OsvEntry.counterparty_id).order_by(OsvEntry.id))).all() + others = select(func.count()).select_from(AuditEvent).where(AuditEvent.action != AuditAction.LOGIN) + events_before = (await seeded.execute(others)).scalar_one() # вход (LOGIN) пишет клиент теста, не экспорт + + assert (await client.get(_url(world))).status_code == HTTP_OK + + after = (await seeded.execute(select(OsvEntry.id, OsvEntry.counterparty_id).order_by(OsvEntry.id))).all() + assert after == before + assert (await seeded.execute(others)).scalar_one() == events_before + 1 + assert len(await _export_events(seeded)) == 1 diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 1ba80c0..c1f114b 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -61,6 +61,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/checklist": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"}, + "/api/v1/consolidations/versions/{version_id}/osv/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"}, } @@ -161,6 +162,17 @@ def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut") +def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None: + """Экспорт: 200 — файл xlsx (бинарный контент), 404/422; фильтры те же, что у страницы, но без пагинации.""" + export = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/export"]["get"] + assert {"200", "404", "422"} <= set(export["responses"]) + xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + assert export["responses"]["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"} + names = {parameter["name"] for parameter in export["parameters"]} + assert {"legal_entity_id", "account_root", "counterparty_type", "unmapped", "match_kind", "q"} <= names + assert not {"limit", "offset"} & names + + def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None: """Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422.""" lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 0f94aa1..1b89bdb 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -236,9 +236,9 @@ - `ConsolidatedOsvService.export(version_id, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str filename]`; аудит `EXPORT` (`section = EXPORT`, `after = {report: "consolidated_osv", filters: f.model_dump(exclude_none=True), rows: len(rows)}`). - Маршрут `GET …/osv/export` → `Response(content, media_type=…, headers={"Content-Disposition": f'attachment; filename="svod-osv.xlsx"; filename*=UTF-8\'\'{quote(name)}'})`. -- [ ] **Step 1: Тесты.** Сценарий «Экспорт читается обратно» (`openpyxl.load_workbook(BytesIO(resp.content))`: заголовки, 3 строки, `Decimal(str(cell.value))` равен API, лист «Пробелы» с `Q2`, аудит `rows = 3`); Review Focus 5 — пустой лист при `unmapped=true`; `Content-Disposition` содержит `filename*=UTF-8''`; `expert` (VIEW) → 200, без права → 403. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_osv_export.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов`. +- [x] **Step 1: Тесты.** Сценарий «Экспорт читается обратно» (`openpyxl.load_workbook(BytesIO(resp.content))`: заголовки, 3 строки, `Decimal(str(cell.value))` равен API, лист «Пробелы» с `Q2`, аудит `rows = 3`); Review Focus 5 — пустой лист при `unmapped=true`; `Content-Disposition` содержит `filename*=UTF-8''`; `expert` (VIEW) → 200, без права → 403. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_osv_export.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): экспорт сводной ОСВ в xlsx с листом пробелов`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index e7458b1..1d48382 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -255,3 +255,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 68 | 2026-10-06 | `alias_moved_from` (ответ и `after_value` `OSV_COUNTERPARTY_MAP`) — один `legal_entity_id`: первый владелец по `(created_at, id)`; при нескольких владельцах (возможны только из данных до общей блокировки) удаляются все их алиасы, каждый со своим `LEGAL_ENTITY_CHANGE`; `ALIAS_TAKEN.owners` — все владельцы в том же порядке | схема `MapCounterpartyOut.alias_moved_from: UUID \| None` (Task 3) | остальные прежние владельцы видны только в `LEGAL_ENTITY_CHANGE` | | 69 | 2026-10-06 | `MapCounterpartyOut.row` — `ConsolidatedRowOut \| null`: строка привязанного документа, которая в свод не попадает (пара с пробелом, квартальный документ при нарастающем), сопоставляется, но строки свода для ответа нет — `row = null` | спека разрешает сопоставлять любую строку привязанного ОСВ-документа; выдумывать строку свода из одной исходной нельзя | фронт обрабатывает `null` (из UI такая строка не выбирается — её нет в своде) | | 70 | 2026-10-06 | Кандидаты: `entry_id` не строки свода версии (чужой, непривязанный, несуществующий) или имя с пустой нормализацией — рекомендаций нет, не 404; `recommended` ищется по `CounterpartyIndex.by_normalized` (имена и алиасы всего справочника; загружается только при `entry_id`); `q` обрезается по краям (`scope=all`: пустой или короче 3 — 422 `QUERY_TOO_SHORT`; `max_length` 500); ИНН — префикс без пробелов внутри `q`; алиас — подстрока `legal_entity_alias.alias`; `scope=group` = ГК консолидации ∪ периметр версии; признак — `domain.classify` | D-11 говорит «если передан»; нормализация — функция Python, в SQL её нет, а индекс — тот же, что у резолва свода | лишняя загрузка справочника на запрос с `entry_id` (как у `GET …/osv`) | +| 71 | 2026-10-06 | Экспорт: лист «Пробелы» строится по своду целиком (`Consolidated.gaps`) и от фильтров строк не зависит; при `unmapped=true` без совпадений лист «Сводная ОСВ» — только заголовок, «Пробелы» заполнен, `audit.rows = 0` (Review Focus 5) | D-14 и спека описывают лист пробелов отдельно от фильтров; пробел — свойство версии, а не строк, и фильтр не должен прятать неполноту данных | файл с фильтром показывает пробелы, не относящиеся к выбранным строкам; читателю это видно по листу | +| 72 | 2026-10-06 | Колонки листа: «Контрагент»/«ИНН контрагента» — как в файле ОСВ (`counterparty_name`/`counterparty_inn`), «Сопоставлено с» — имя ЮЛ справочника (`counterparty_entity_name`, пусто без сопоставления), «Тип контрагента» — код `GSK`/`GK`/`EXTERNAL`, «Способ сопоставления» — код `match_kind` (пусто без него); пустые значения — пустые ячейки | D-14 перечисляет колонки без значений; коды совпадают с API, и выгрузка сверяется со страницей свода построчно | если банк захочет русские подписи типа — правка одного словаря в `osv_export` | +| 73 | 2026-10-06 | Суммы пишутся `Decimal` напрямую; `openpyxl` сериализует число в XML через `float`-представление (формат xlsx — IEEE-754 double), значения свыше ~10^15 теряют младшие разряды; защит в коде нет | суммы ОСВ в рублях на три порядка меньше предела (`numeric(18,2)` — запас, не потребность); тест сверяет `Decimal(str(cell.value))` с API | сумма от 10^15 руб. в выгрузке будет округлена, в БД и API — точная | +| 74 | 2026-10-06 | Имя файла: `svod-osv_<ГК>_<год>-_v.xlsx`, имя ГК берётся как есть и целиком кодируется `quote(safe="")` (пробел, кавычки, `;`, `/`, `'`, переводы строк, кириллица) — заголовок `Content-Disposition` чистый ASCII, `filename="svod-osv.xlsx"` фиксирован; квартал — `Q1..Q4` | D-14: `<год>-<квартал>`; фиксированное ASCII-имя защищает от инъекции в заголовок | клиент без поддержки `filename*` сохранит файл как `svod-osv.xlsx` | +| 75 | 2026-10-06 | `build_workbook(c, rows, *, entity_names)`: параметр `entity_names` сохранён по плану как переопределение отображаемых имён ЮЛ по id, без записи в словаре — имя из строки/пробела; сервис передаёт `{}`, так как строки и пробелы уже несут имена | интерфейс зафиксирован планом; убирать параметр — менять контракт задачи | параметр сейчас не используется сервисом; кандидат на удаление при ревью | +| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index a269b23..2a1b473 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -51,7 +51,7 @@ - [x] 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`, +- [x] 4.4 **Экспорт.** `services/osv_export.py` (openpyxl, два листа, `BytesIO`), `GET …/osv/export`, `Content-Disposition`, аудит `EXPORT`. Тест: файл читается `openpyxl`, суммы равны API, лист «Пробелы». ## 5. Сквозной тест и документы -- 2.47.2 From 5b3b1011cb4fcecede5a30cf0382f3d5cc33ec20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:12:45 +0400 Subject: [PATCH 19/27] =?UTF-8?q?fix(consolidation):=20=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20xlsx,=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строки пишутся по явному счётчику (sheet.max_row делал сборку квадратичной: 6 000 строк — 8.4 с → 0.5 с), сборка вынесена в run_in_threadpool. Тексты проходят _put_text: запрещённые XML-символы вырезаются, тип ячейки строковый — ведущие =, +, -, @ не становятся формулами (R-12, design #77). Параметр entity_names удалён (R-13, #78), порог точности double в #73 исправлен, тесты пустых сумм и 6 000 строк. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/consolidated_osv.py | 3 +- api/src/services/osv_export.py | 93 +++++++++------ api/tests/test_api_osv_export.py | 22 ++++ api/tests/test_osv_export_workbook.py | 111 ++++++++++++++++++ .../plans/2026-10-06-slice-4-consolidation.md | 2 +- .../changes/slice-4-consolidation/design.md | 8 +- 6 files changed, 196 insertions(+), 43 deletions(-) create mode 100644 api/tests/test_osv_export_workbook.py diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 42a1b7e..c20686b 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -15,6 +15,7 @@ import uuid from collections.abc import Collection, Sequence from typing import Literal +from fastapi.concurrency import run_in_threadpool from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -176,7 +177,7 @@ class ConsolidatedOsvService: percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" consolidated, version, consolidation = await self._build(version_id) rows = domain.apply_filter(consolidated.rows, domain_filter(f)) - content = build_workbook(consolidated, rows, entity_names={}) + content = await run_in_threadpool(build_workbook, consolidated, rows) group_name = ( await self._session.execute( select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) diff --git a/api/src/services/osv_export.py b/api/src/services/osv_export.py index e530e68..62b32e0 100644 --- a/api/src/services/osv_export.py +++ b/api/src/services/osv_export.py @@ -2,16 +2,24 @@ Лист «Сводная ОСВ» — строки свода в переданном порядке (порядок API, D-16), шесть сумм — числовые ячейки `#,##0.00` в рублях без деления. В ячейки пишется `Decimal` как есть: `float` в нашем коде не используется; -`openpyxl` сам сериализует число в XML (формат xlsx хранит числа как IEEE-754 double, поэтому значения свыше -~10^15 теряют младшие разряды — для сумм ОСВ в рублях запас на три порядка). Лист «Пробелы» — пробелы свода +`openpyxl` сам сериализует число в XML через `%.16g` (формат xlsx хранит числа как IEEE-754 double): значения до +10^13 с копейками читаются обратно точно, выше ~10^13 младшая копейка может потеряться (`96729051052621.71` → +`…621.7`), а `9999999999999999.99` станет `1e+16`. Для сумм ОСВ в рублях вывод прежний: сам Excel держит 15 цифр. +Лист «Пробелы» — пробелы свода целиком (`Consolidated.gaps`), фильтры строк на него не влияют; без пробелов — одна ячейка «пробелов нет». + +Тексты приходят из загруженных внешних файлов (R-12): в книгу они попадают только через `_text` / `_put_text` — +запрещённые XML-символы вырезаются (иначе `IllegalCharacterError` → 500), тип ячейки принудительно строковый (`"s"`), +поэтому ведущие `=`, `+`, `-`, `@` не превращаются в формулы Excel, а текст читается обратно без изменений. +Строки пишутся по явному счётчику (`sheet.max_row` в `openpyxl` — O(n) на вызов, цикл по нему квадратичен). """ -import uuid -from collections.abc import Mapping, Sequence +from collections.abc import Sequence +from decimal import Decimal from io import BytesIO from openpyxl import Workbook +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet @@ -37,59 +45,68 @@ def _widths(sheet: Worksheet, widths: Sequence[int]) -> None: sheet.column_dimensions[get_column_letter(index)].width = width -def _main_sheet(sheet: Worksheet, rows: Sequence[ConsolidatedRow], entity_names: Mapping[uuid.UUID, str]) -> None: +def _text(value: str | None) -> str | None: + """Текст для ячейки: запрещённые XML-символы вырезаны (`IllegalCharacterError` → 500), `None` остаётся пустым.""" + return None if value is None else ILLEGAL_CHARACTERS_RE.sub("", value) + + +def _put_text(sheet: Worksheet, row: int, column: int, value: str | None) -> None: + """Строковая ячейка: тип `"s"` принудительно — значение с ведущим `=` не становится формулой (R-12).""" + text = _text(value) + if text is None: + return + cell = sheet.cell(row=row, column=column, value=text) + cell.data_type = "s" + + +def _put_money(sheet: Worksheet, row: int, column: int, value: Decimal | None) -> None: + """Числовая ячейка `#,##0.00`; `None` (сумма не задана) — пустая ячейка, не ноль.""" + if value is None: + return + sheet.cell(row=row, column=column, value=value).number_format = MONEY_FORMAT + + +def _main_sheet(sheet: Worksheet, rows: Sequence[ConsolidatedRow]) -> None: sheet.title = MAIN_SHEET sheet.append(MAIN_HEADER) - for row in rows: - sheet.append( - [ - entity_names.get(row.legal_entity_id, row.legal_entity_name), - row.legal_entity_inn, - row.account_code, - row.period, - row.counterparty_name, - row.counterparty_inn, - row.opening_debit, - row.opening_credit, - row.turnover_debit, - row.turnover_credit, - row.closing_debit, - row.closing_credit, - row.counterparty_type.value, - row.counterparty_entity_name, - None if row.match_kind is None else row.match_kind.value, - ] - ) - for cell in sheet[sheet.max_row][FIRST_MONEY_COLUMN - 1 : LAST_MONEY_COLUMN]: - cell.number_format = MONEY_FORMAT + for line, row in enumerate(rows, start=2): # явный счётчик строк: `sheet.max_row` — O(n) на вызов + texts = (row.legal_entity_name, row.legal_entity_inn, row.account_code, row.period, row.counterparty_name, + row.counterparty_inn) # fmt: skip + for column, text in enumerate(texts, start=1): + _put_text(sheet, line, column, text) + amounts = (row.opening_debit, row.opening_credit, row.turnover_debit, row.turnover_credit, row.closing_debit, + row.closing_credit) # fmt: skip + for column, amount in enumerate(amounts, start=FIRST_MONEY_COLUMN): + _put_money(sheet, line, column, amount) + _put_text(sheet, line, LAST_MONEY_COLUMN + 1, row.counterparty_type.value) + _put_text(sheet, line, LAST_MONEY_COLUMN + 2, row.counterparty_entity_name) + _put_text(sheet, line, LAST_MONEY_COLUMN + 3, None if row.match_kind is None else row.match_kind.value) sheet.freeze_panes = "A2" _widths(sheet, (32, 14, 8, 10, 36, 14, 16, 16, 16, 16, 16, 16, 12, 32, 14)) -def _gaps_sheet(sheet: Worksheet, consolidated: Consolidated, entity_names: Mapping[uuid.UUID, str]) -> None: +def _gaps_sheet(sheet: Worksheet, consolidated: Consolidated) -> None: sheet.title = GAPS_SHEET if not consolidated.gaps: sheet.append([NO_GAPS]) return sheet.append(GAPS_HEADER) - for gap in consolidated.gaps: - quarters = ", ".join(quarter.value for quarter in gap.missing_quarters) - sheet.append([entity_names.get(gap.legal_entity_id, gap.legal_entity_name), gap.account_code, quarters]) + for line, gap in enumerate(consolidated.gaps, start=2): + _put_text(sheet, line, 1, gap.legal_entity_name) + _put_text(sheet, line, 2, gap.account_code) + _put_text(sheet, line, 3, ", ".join(quarter.value for quarter in gap.missing_quarters)) _widths(sheet, (32, 8, 24)) -def build_workbook( - consolidated: Consolidated, rows: Sequence[ConsolidatedRow], *, entity_names: Mapping[uuid.UUID, str] -) -> bytes: +def build_workbook(consolidated: Consolidated, rows: Sequence[ConsolidatedRow]) -> bytes: """Книга из двух листов: `rows` (уже отфильтрованные строки свода) и пробелы `consolidated.gaps`. - `entity_names` — отображаемые имена ЮЛ по идентификатору (переопределяют имена из строк и пробелов; нет в - словаре — имя из самой строки/пробела). Только память: `BytesIO`, S3 и БД не используются.""" + Чистая функция: только память (`BytesIO`), ни БД, ни S3; время линейно по числу строк.""" workbook = Workbook() main = workbook.active assert main is not None # новая книга всегда с активным листом - _main_sheet(main, rows, entity_names) - _gaps_sheet(workbook.create_sheet(), consolidated, entity_names) + _main_sheet(main, rows) + _gaps_sheet(workbook.create_sheet(), consolidated) buffer = BytesIO() workbook.save(buffer) return buffer.getvalue() diff --git a/api/tests/test_api_osv_export.py b/api/tests/test_api_osv_export.py index c0de420..7768e09 100644 --- a/api/tests/test_api_osv_export.py +++ b/api/tests/test_api_osv_export.py @@ -321,3 +321,25 @@ async def test_export_writes_only_the_audit_event(auth_client: AuthClientFactory assert after == before assert (await seeded.execute(others)).scalar_one() == events_before + 1 assert len(await _export_events(seeded)) == 1 + + +@pytest.mark.integration +async def test_formula_like_and_control_characters_in_external_names( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """R-12: имена приходят из загруженных файлов — формула не вычисляется, `\x0b` вырезается, ответ 200.""" + world = await _world(seeded) + names = {"Неизвестный контрагент": '=HYPERLINK("http://x","y")', "ООО Внешний": "a\x0bb"} + for entry in (await seeded.execute(select(OsvEntry).where(OsvEntry.counterparty_name.in_(list(names))))).scalars(): + entry.counterparty_name = names[entry.counterparty_name] + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.get(_url(world)) + + assert response.status_code == HTTP_OK, response.text + cells = { + (cell.data_type, cell.value) + for (cell,) in _book(response.content)[MAIN].iter_rows(min_row=2, min_col=5, max_col=5) + } + assert ("s", '=HYPERLINK("http://x","y")') in cells and ("s", "ab") in cells diff --git a/api/tests/test_osv_export_workbook.py b/api/tests/test_osv_export_workbook.py new file mode 100644 index 0000000..51f4c81 --- /dev/null +++ b/api/tests/test_osv_export_workbook.py @@ -0,0 +1,111 @@ +"""`build_workbook` без БД (spec consolidated-osv «Экспорт xlsx»; design D-14, #77): линейное время, пустые суммы, +защита от формул Excel и запрещённых символов.""" + +import io +import time +import uuid +from decimal import Decimal + +import pytest +from openpyxl import load_workbook + +from src.domain.consolidated_osv import Consolidated, ConsolidatedRow, CounterpartyType, GapItem +from src.models.enums import CounterpartyMatchKind, PeriodQuarter +from src.services.osv_export import GAPS_SHEET, MAIN_SHEET, build_workbook + +D = Decimal +BIG_ROWS = 6000 +BIG_MAX_SECONDS = 3.0 # квадратичная версия на 6 000 строках — 8.4 с, линейная — 0.5 с (запас на загруженный CI) +ENTITY = uuid.uuid4() +INJECTIONS = ['=HYPERLINK("http://x","y")', "=A1+1", "@SUM(1)", "+1", "-1+2"] + + +def _row(name: str = "ООО Б", **overrides: object) -> ConsolidatedRow: + fields: dict[str, object] = { + "entry_ids": (uuid.uuid4(),), + "legal_entity_id": ENTITY, + "legal_entity_name": "ООО А", + "legal_entity_inn": "1234567890", + "account_code": "60", + "account_root": "60", + "period": "2024 Q3", + "is_cumulative": False, + "row_number": 1, + "counterparty_name": name, + "counterparty_inn": "0987654321", + "counterparty_id": None, + "counterparty_entity_name": None, + "counterparty_type": CounterpartyType.EXTERNAL, + "match_kind": None, + "match_stored": False, + "opening_debit": D("100.05"), + "opening_credit": D("0.00"), + "turnover_debit": D("10.10"), + "turnover_credit": D("0.00"), + "closing_debit": D("110.15"), + "closing_credit": D("0.00"), + } + fields.update(overrides) + return ConsolidatedRow(**fields) # type: ignore[arg-type] + + +def _consolidated(rows: tuple[ConsolidatedRow, ...], gaps: tuple[GapItem, ...] = ()) -> Consolidated: + return Consolidated(rows=rows, gaps=gaps, documents_total=1) + + +def _sheet(content: bytes, name: str = MAIN_SHEET): # noqa: ANN202 - тип листа openpyxl не типизирован + return load_workbook(io.BytesIO(content))[name] + + +def test_build_is_linear_for_thousands_of_rows() -> None: + rows = tuple(_row(f"Контрагент {n}") for n in range(BIG_ROWS)) + + started = time.perf_counter() + content = build_workbook(_consolidated(rows), rows) + elapsed = time.perf_counter() - started + + print(f"\nPERF build_workbook {BIG_ROWS} rows: {elapsed:.2f}s") # noqa: T201 + assert elapsed < BIG_MAX_SECONDS + sheet = _sheet(content) + assert sheet.max_row == BIG_ROWS + 1 + assert sheet.cell(BIG_ROWS + 1, 5).value == f"Контрагент {BIG_ROWS - 1}" + + +def test_none_amounts_are_empty_cells_not_zero() -> None: + row = _row(opening_debit=None, opening_credit=D("1.00"), turnover_debit=None, turnover_credit=None, + closing_debit=D("0.00"), closing_credit=None) # fmt: skip + + sheet = _sheet(build_workbook(_consolidated((row,)), (row,))) + + assert [sheet.cell(2, column).value for column in range(7, 13)] == [None, 1, None, None, 0, None] + assert sheet.cell(2, 8).number_format == "#,##0.00" and sheet.cell(2, 8).data_type == "n" + assert sheet.cell(2, 13).value == "EXTERNAL" and sheet.cell(2, 14).value is None and sheet.cell(2, 15).value is None + + +@pytest.mark.parametrize("text", INJECTIONS) +def test_formula_like_text_stays_plain_string(text: str) -> None: + gap = GapItem(ENTITY, text, text, (PeriodQuarter.Q1, PeriodQuarter.Q2)) + row = _row( + text, legal_entity_name=text, legal_entity_inn=text, account_code=text, counterparty_inn=text, + counterparty_entity_name=text, counterparty_type=CounterpartyType.GSK, match_kind=CounterpartyMatchKind.NAME, + ) # fmt: skip + content = build_workbook(_consolidated((row,), (gap,)), (row,)) + + main = _sheet(content) + for column in (1, 2, 3, 5, 6, 14): + cell = main.cell(2, column) + assert (cell.data_type, cell.value) == ("s", text), column + gaps = _sheet(content, GAPS_SHEET) + assert [(cell.data_type, cell.value) for cell in gaps[2][:2]] == [("s", text), ("s", text)] + assert gaps.cell(2, 3).value == "Q1, Q2" + + +def test_control_characters_are_stripped_not_500() -> None: + row = _row("a\x0bb\x00c", legal_entity_name="ООО\x1f А", counterparty_entity_name="\x08Эф") + gap = GapItem(ENTITY, "Б\x0b", "6\x0b0", (PeriodQuarter.Q2,)) + + content = build_workbook(_consolidated((row,), (gap,)), (row,)) + + main = _sheet(content) + assert (main.cell(2, 5).value, main.cell(2, 1).value, main.cell(2, 14).value) == ("abc", "ООО А", "Эф") + assert [cell.value for cell in _sheet(content, GAPS_SHEET)[2]] == ["Б", "60", "Q2"] diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 1b89bdb..0573cae 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -232,7 +232,7 @@ - Modify: `api/requirements-dev.txt` (ничего, openpyxl есть), `api/pyproject.toml` (mypy override `openpyxl.*` `ignore_missing_imports`, если стабы не установлены) **Interfaces:** -- `def build_workbook(c: Consolidated, rows: Sequence[ConsolidatedRow], *, entity_names: Mapping[uuid.UUID, str]) -> bytes` — `openpyxl.Workbook()`, лист «Сводная ОСВ» (15 колонок D-14, `number_format = "#,##0.00"`, суммы `float(Decimal)` недопустимы → писать `Decimal` напрямую: openpyxl принимает `Decimal` как число), лист «Пробелы»; `BytesIO`. +- `def build_workbook(c: Consolidated, rows: Sequence[ConsolidatedRow]) -> bytes` — `openpyxl.Workbook()`, лист «Сводная ОСВ» (15 колонок D-14, `number_format = "#,##0.00"`, суммы `float(Decimal)` недопустимы → писать `Decimal` напрямую: openpyxl принимает `Decimal` как число), лист «Пробелы»; `BytesIO`. - `ConsolidatedOsvService.export(version_id, f: OsvFilters, audit: RequestAudit) -> tuple[bytes, str filename]`; аудит `EXPORT` (`section = EXPORT`, `after = {report: "consolidated_osv", filters: f.model_dump(exclude_none=True), rows: len(rows)}`). - Маршрут `GET …/osv/export` → `Response(content, media_type=…, headers={"Content-Disposition": f'attachment; filename="svod-osv.xlsx"; filename*=UTF-8\'\'{quote(name)}'})`. diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 1d48382..1ccdb30 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -257,7 +257,9 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 70 | 2026-10-06 | Кандидаты: `entry_id` не строки свода версии (чужой, непривязанный, несуществующий) или имя с пустой нормализацией — рекомендаций нет, не 404; `recommended` ищется по `CounterpartyIndex.by_normalized` (имена и алиасы всего справочника; загружается только при `entry_id`); `q` обрезается по краям (`scope=all`: пустой или короче 3 — 422 `QUERY_TOO_SHORT`; `max_length` 500); ИНН — префикс без пробелов внутри `q`; алиас — подстрока `legal_entity_alias.alias`; `scope=group` = ГК консолидации ∪ периметр версии; признак — `domain.classify` | D-11 говорит «если передан»; нормализация — функция Python, в SQL её нет, а индекс — тот же, что у резолва свода | лишняя загрузка справочника на запрос с `entry_id` (как у `GET …/osv`) | | 71 | 2026-10-06 | Экспорт: лист «Пробелы» строится по своду целиком (`Consolidated.gaps`) и от фильтров строк не зависит; при `unmapped=true` без совпадений лист «Сводная ОСВ» — только заголовок, «Пробелы» заполнен, `audit.rows = 0` (Review Focus 5) | D-14 и спека описывают лист пробелов отдельно от фильтров; пробел — свойство версии, а не строк, и фильтр не должен прятать неполноту данных | файл с фильтром показывает пробелы, не относящиеся к выбранным строкам; читателю это видно по листу | | 72 | 2026-10-06 | Колонки листа: «Контрагент»/«ИНН контрагента» — как в файле ОСВ (`counterparty_name`/`counterparty_inn`), «Сопоставлено с» — имя ЮЛ справочника (`counterparty_entity_name`, пусто без сопоставления), «Тип контрагента» — код `GSK`/`GK`/`EXTERNAL`, «Способ сопоставления» — код `match_kind` (пусто без него); пустые значения — пустые ячейки | D-14 перечисляет колонки без значений; коды совпадают с API, и выгрузка сверяется со страницей свода построчно | если банк захочет русские подписи типа — правка одного словаря в `osv_export` | -| 73 | 2026-10-06 | Суммы пишутся `Decimal` напрямую; `openpyxl` сериализует число в XML через `float`-представление (формат xlsx — IEEE-754 double), значения свыше ~10^15 теряют младшие разряды; защит в коде нет | суммы ОСВ в рублях на три порядка меньше предела (`numeric(18,2)` — запас, не потребность); тест сверяет `Decimal(str(cell.value))` с API | сумма от 10^15 руб. в выгрузке будет округлена, в БД и API — точная | +| 73 | 2026-10-06 | Суммы пишутся `Decimal` напрямую; `openpyxl` сериализует число в XML через `%.16g` (формат xlsx — IEEE-754 double): значения до 10^13 с копейками читаются обратно точно, выше ~10^13 младшая копейка может потеряться (`96729051052621.71` → `…621.7`; ≈1,3 % случайных значений в 10^13–10^14), `9999999999999999.99` станет `1e+16`; защит в коде нет (порог уточнён ревью T12.3) | суммы ОСВ в рублях на практике ниже 10^13, а сам Excel держит 15 значащих цифр; тест сверяет `Decimal(str(cell.value))` с API | сумма от 10^13 руб. в выгрузке может потерять копейку, в БД и API — точная | | 74 | 2026-10-06 | Имя файла: `svod-osv_<ГК>_<год>-_v.xlsx`, имя ГК берётся как есть и целиком кодируется `quote(safe="")` (пробел, кавычки, `;`, `/`, `'`, переводы строк, кириллица) — заголовок `Content-Disposition` чистый ASCII, `filename="svod-osv.xlsx"` фиксирован; квартал — `Q1..Q4` | D-14: `<год>-<квартал>`; фиксированное ASCII-имя защищает от инъекции в заголовок | клиент без поддержки `filename*` сохранит файл как `svod-osv.xlsx` | -| 75 | 2026-10-06 | `build_workbook(c, rows, *, entity_names)`: параметр `entity_names` сохранён по плану как переопределение отображаемых имён ЮЛ по id, без записи в словаре — имя из строки/пробела; сервис передаёт `{}`, так как строки и пробелы уже несут имена | интерфейс зафиксирован планом; убирать параметр — менять контракт задачи | параметр сейчас не используется сервисом; кандидат на удаление при ревью | -| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить | +| 75 | 2026-10-06 | `build_workbook(c, rows, *, entity_names)`: параметр `entity_names` сохранён по плану как переопределение отображаемых имён ЮЛ по id, без записи в словаре — имя из строки/пробела; сервис передаёт `{}`, так как строки и пробелы уже несут имена | интерфейс зафиксирован планом; убирать параметр — менять контракт задачи | параметр сейчас не используется сервисом; кандидат на удаление при ревью (снято решением #78) | +| 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить; `model_dump(mode="json", exclude_none=True)` отличается от буквального `model_dump` плана (M-5): UUID и enum должны быть JSON-сериализуемы для `audit_event` | +| 77 | 2026-10-06 | R-12: все текстовые ячейки экспорта (компания, ИНН, счёт, период, контрагент, ИНН контрагента, «Сопоставлено с», тип, способ, лист «Пробелы») проходят `_put_text`: запрещённые XML-символы (`ILLEGAL_CHARACTERS_RE`) вырезаются, тип ячейки принудительно `"s"`, поэтому `=`, `+`, `-`, `@` в начале не становятся формулой, а текст читается обратно без изменений | файлы внешние, аналитики открывают выгрузку в Excel: без этого `=HYPERLINK(...)` из чужого файла выполнился бы в их книге, а управляющий символ давал `IllegalCharacterError` → 500 | текст ячейки с ведущим `=` больше не вычисляется — это желаемо; вырезанный управляющий символ из выгрузки пропадает | +| 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | -- 2.47.2 From af91643811ea149d3145839f23bf914c69884271 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:25:30 +0400 Subject: [PATCH 20/27] =?UTF-8?q?test(consolidation):=20=D1=81=D0=BA=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=BD=D0=BE=D0=B9=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B9=20=D0=A1=D1=80=D0=B5=D0=B7=D0=B0=204=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=C2=AB=D0=A0?= =?UTF-8?q?=D1=83=D1=81=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B1=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер map_golden_snapshot, сценарий маппинг golden -> подтверждение -> консолидация 2024 Q3 -> свод, сопоставление Счастливочной, экспорт, независимая сверка склейки периодов. Тест помечен xfail(strict): на реальных данных нарастающий Q1 (is_cumulative=true) не привязывается к версии Q3 и не считается квартальным (fits / select_sources) — свод пуст; подробности в отчёте задачи. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/fixtures_helpers.py | 58 ++++ api/tests/test_consolidation_fixtures.py | 297 ++++++++++++++++++ api/tests/test_mapping_golden.py | 10 +- .../plans/2026-10-06-slice-4-consolidation.md | 4 +- .../changes/slice-4-consolidation/design.md | 2 + .../changes/slice-4-consolidation/tasks.md | 4 +- 6 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 api/tests/test_consolidation_fixtures.py diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 242ece7..2987c65 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -1,7 +1,19 @@ """Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`.""" +import json import os +import uuid +from datetime import UTC, datetime from pathlib import Path +from typing import TYPE_CHECKING + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +if TYPE_CHECKING: + from src.contract.result import RecognitionResult + from src.models.directories import LegalEntity + from src.models.files import Document FIXTURES_ENV = "ARM_KA_FIXTURES" @@ -26,3 +38,49 @@ def fixtures_skip_reason() -> str | None: f"ARM_KA_FIXTURES недоступен: нет каталога {path} " f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)" ) + + +def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "RecognitionResult": + """Golden-снимок парсера как `RecognitionResult`: подставляет `job_id`/`file_id` и версию сервиса.""" + from src.contract.result import RecognitionResult # noqa: PLC0415 - тяжёлые импорты лениво: цикл с conftest + + data = json.loads(path.read_text("utf-8")) + data["service"] = {"version": "golden", **data["service"]} + return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) + + +async def map_golden_snapshot( + session: AsyncSession, *, entity: "LegalEntity", path: Path, uploaded_by: uuid.UUID | None +) -> "list[Document]": + """Golden-снимок `path` → файл ЮЛ `entity` и его документы ОСВ в БД теста (`flush`, без commit). + + Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и настоящим + `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы до маппинга). + Возвращает документы файла (после автозамены прежних версий — только документы этого файла). + """ + # ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл) + from src.models.accounting import ChartOfAccount # noqa: PLC0415 + from src.models.enums import FileStatus, RecognitionStatus # noqa: PLC0415 + from src.models.files import Document # noqa: PLC0415 + from src.repositories.counterparties import CounterpartyIndex # noqa: PLC0415 + from src.services.result_mapping import map_file # noqa: PLC0415 + from tests.factories import make_stored_file # noqa: PLC0415 + + file, job = await make_stored_file( + session, + entity=entity, + status=FileStatus.PROCESSING, + job_status=RecognitionStatus.done, + original_name=path.name, + uploaded_by=uploaded_by, + ) + chart = { + code: account_id for code, account_id in await session.execute(select(ChartOfAccount.code, ChartOfAccount.id)) + } + index = await CounterpartyIndex.load(session) + result = golden_result(path, job_id=job.id, file_id=file.id) + await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC)) + documents = await session.execute( + select(Document).where(Document.stored_file_id == file.id).order_by(Document.osv_account_code) + ) + return list(documents.scalars()) diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py new file mode 100644 index 0000000..26e677f --- /dev/null +++ b/api/tests/test_consolidation_fixtures.py @@ -0,0 +1,297 @@ +"""Сквозной сценарий Среза 4 на реальных данных группы «Русский бисквит» (tasks 5.1; spec consolidated-osv «Признаки на +фикстурах РБ», «Сопоставление из плана среза», «Рекомендуемая Счастливочная»; documents-api «Фикстуры, подтверждённые +до консолидации»). + +Golden-снимки парсера `rb/2024-Q1..Q3` и `schastl/2024-Q1..Q3` → боевой `map_file` → подтверждение через +`POST /documents/{id}/confirm` → создание консолидации 2024 Q3 (подхват подтверждённых) → блокировка → сводная ОСВ, +сводка и пробелы, ручное сопоставление «Пекарни Счастливочной» с автоприменением → xlsx-экспорт. Суммы свода сверяются с +суммой квартальных значений, прочитанных из golden-JSON независимо от доменного кода. +""" + +import io +import json +import time +import uuid +from collections import defaultdict +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from httpx import AsyncClient +from loguru import logger +from openpyxl import load_workbook +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.aliases import normalize_alias +from src.domain.osv_mapping import normalize_account_code +from src.models.access import AppUser +from src.models.consolidation import ConsolidationDocument +from src.models.directories import RegionalOffice +from src.models.enums import CounterpartyMatchKind, DocumentStatus +from src.models.files import Document +from src.models.osv import OsvEntry +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group +from tests.fixtures_helpers import map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +HTTP_OK = 200 +HTTP_CREATED = 201 +GOLDEN = Path("golden") / "expected_osv_parse" +KA_RF = "ka_rf@armka.local" +ROSTOV = "Ростовский региональный филиал" # филиал dev-пользователя ka_rf (в плане — СПб РФ: у ka_rf его нет) +RB_INN = "3528052718" # АО «Русский бисквит» (ЕЦП-выписка 01.04.26) +SCHASTL_INN = "3528104476" # ООО «Пекарня Счастливочная» (там же) +SCHASTL_FILE_NAME = "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" # в файлах РБ контрагент записан «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» +RB_NAME_IN_SCHASTL = "Русский бисквит" +TIME_BUDGET_SECONDS = 60.0 +Q1, Q2, Q3 = "Q1", "Q2", "Q3" +SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") + + +def _is_schastl(name: str | None) -> bool: + return SCHASTL_FILE_NAME.casefold() in normalize_alias(name or "") + + +def _golden_files(fixtures_dir: Path, folder: str) -> list[Path]: + paths = [ + p for quarter in (1, 2, 3) for p in sorted((fixtures_dir / GOLDEN / folder / f"2024-Q{quarter}").glob("*.json")) + ] + assert paths, folder + return paths + + +def _golden_rows(fixtures_dir: Path, folder: str, account_root: str) -> dict[str, list[dict[str, Any]]]: + """Строки-контрагенты golden-снимков ОСВ счёта `account_root` по кварталам — для независимого расчёта.""" + result: dict[str, list[dict[str, Any]]] = {Q1: [], Q2: [], Q3: []} + for path in _golden_files(fixtures_dir, folder): + for sheet in json.loads(path.read_text("utf-8"))["documents"]: + header = sheet["header"] + if sheet["document_type"] != "OSV" or header["account_code_raw"].split(".")[0] != account_root: + continue + result[header["period_quarter"]].extend(r for r in sheet["rows"] if r["group_type"] == "COUNTERPARTY") + return result + + +def _key(row: dict[str, Any]) -> tuple[str, str, str]: + return ( + normalize_account_code(row["account_code_raw"]), + (row["counterparty_name"] or "").strip(), + (row["counterparty_inn"] or "").replace(" ", ""), + ) + + +def _dec(value: str | None) -> Decimal: + return Decimal(value) if value is not None else Decimal(0) + + +async def _get(client: AsyncClient, url: str, **params: Any) -> Any: + response = await client.get(url, params=params) + assert response.status_code == HTTP_OK, response.text + return response.json() + + +async def _all_rows(client: AsyncClient, version_url: str, **params: Any) -> list[dict[str, Any]]: + page = await _get(client, f"{version_url}/osv", limit=2000, **params) + assert page["total"] == len(page["items"]), "свод не помещается в одну страницу" + items: list[dict[str, Any]] = page["items"] + return items + + +@pytest.mark.xfail( + strict=True, + reason=( + "ДЕФЕКТ Срезов 4 (D-5/D-2): golden ОСВ Q1 помечены is_cumulative=true (парсер: первый квартал нарастающий), а " + "`consolidation_documents.fits` привязывает нарастающий документ только за квартал версии, и " + "`consolidation_periods.select_sources` не считает нарастающий Q1 квартальным — для версии Q3 документы Q1 не " + "привязаны (17 из 22), пары 60/62/76 уходят в gaps [Q1], rows_total = 0. Снять xfail после исправления: " + "нарастающий Q1 равнозначен квартальному Q1 для любой версии года (fits + select_sources)" + ), +) +async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0914, PLR0915 - сквозной сценарий в одном месте + auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path +) -> None: + started = time.perf_counter() + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + group = await make_company_group(seeded, name="ГК Русский бисквит", office=office) + gsk = await make_related_group(seeded, group=group, name="ГСК Русский бисквит") + rb = await make_legal_entity(seeded, name="АО «Русский бисквит»", inn=RB_INN, group=group) + schastl = await make_legal_entity(seeded, name="ООО «Пекарня Счастливочная»", inn=SCHASTL_INN, group=group) + for entity in (rb, schastl): + await make_membership(seeded, related_group=gsk, entity=entity) + + documents: list[Document] = [] + for folder, entity in (("rb", rb), ("schastl", schastl)): + for path in _golden_files(fixtures_dir, folder): + documents.extend(await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=user.id)) + assert len(documents) == 11 + 11 # РБ: Q1 2, Q2 2, Q3 7; Счастливочная: Q1 3, Q2 3, Q3 5 + + client = await auth_client(KA_RF) + for document in documents: + response = await client.post(f"/documents/{document.id}/confirm") + assert response.status_code == HTTP_OK, (document.osv_account_code, document.period_quarter, response.text) + + created = await client.post( + "/consolidations", + json={"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": 2024, "quarter": "Q3"}, + ) + assert created.status_code == HTTP_CREATED, created.text + card = created.json() + assert card["status"] == "INITIATED" + version_id = card["id"] + version_url = f"/consolidations/versions/{version_id}" + bound = ( + await seeded.execute( + select(func.count()) + .select_from(ConsolidationDocument) + .where(ConsolidationDocument.consolidation_version_id == uuid.UUID(version_id)) + ) + ).scalar_one() + logger.info("привязано документов к версии: {}", bound) + assert bound == len(documents) # «Фикстуры, подтверждённые до консолидации»: подхвачены все подтверждённые ОСВ + + locked = await client.post(f"{version_url}/lock") + assert locked.status_code == HTTP_CREATED, locked.text + + summary = await _get(client, f"{version_url}/osv/summary") + rows = await _all_rows(client, version_url) + logger.info( + "сводная ОСВ РБ 2024 Q3: строк {}, сводка {}", len(rows), {k: v for k, v in summary.items() if k != "gaps"} + ) + print( + f"\nSVOD: rows={len(rows)} summary={ {k: v for k, v in summary.items() if k != 'gaps'} } gaps={summary['gaps']}" + ) # noqa: T201 + assert summary["rows_total"] == len(rows) + + # строки свода: 60 и 62 у обеих, 76 у Счастливочной + present = {(row["legal_entity_id"], row["account_root"]) for row in rows} + for account in ("60", "62"): + assert (str(rb.id), account) in present + assert (str(schastl.id), account) in present + assert (str(schastl.id), "76") in present + + # пробелы: у РБ нет квартальных ОСВ 66/67/76 за Q1–Q2, у Счастливочной — 01/02 + gaps = {(g["legal_entity_id"], g["account_code"]): g["missing_quarters"] for g in summary["gaps"]} + for account in ("66", "67", "76"): + assert set(gaps[(str(rb.id), account)]) >= {Q1, Q2} + for account in ("01", "02"): + assert set(gaps[(str(schastl.id), account)]) >= {Q1, Q2} + + # чек-лист: ОСВ 58 РБ не подтверждена (есть только 58.03) + checklist = await _get(client, f"{version_url}/checklist") + column = next( + i for i, rule in enumerate(checklist["rules"]) if (rule["document_type"], rule["account"]) == ("OSV", "58") + ) + rb_row = next(r for r in checklist["rows"] if r["legal_entity_id"] == str(rb.id)) + assert rb_row["cells"][column]["status"] != "CONFIRMED" + for account in ("60", "62"): # ОСВ 60/62 РБ за Q1–Q3 подтверждены до консолидации — ячейка CONFIRMED, три документа + cell = rb_row["cells"][ + next(i for i, r in enumerate(checklist["rules"]) if (r["document_type"], r["account"]) == ("OSV", account)) + ] + assert (cell["status"], len(cell["document_ids"])) == ("CONFIRMED", 3), account # noqa: PLR2004 + + # несопоставленные строки РБ «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» + unmapped = await _all_rows(client, version_url, unmapped="true", legal_entity_id=str(rb.id)) + target = [row for row in unmapped if _is_schastl(row["counterparty_name"])] + assert target + first = target[0] + unmapped_before = summary["unmapped_total"] + + mapped = await client.post( + f"{version_url}/osv/entries/{first['entry_ids'][0]}/counterparty", json={"legal_entity_id": str(schastl.id)} + ) + assert mapped.status_code == HTTP_OK, mapped.text + body = mapped.json() + assert body["row"]["counterparty_type"] == "GSK" + assert body["propagated"] >= 1 + + after = await _get(client, f"{version_url}/osv/summary") + rows_after = await _all_rows(client, version_url) + # `propagated` считает записи `osv_entry` (в том числе в парах-пробелах 66/76, строк в своде не дающих), а + # `unmapped_total` — строки свода: квартальные записи одного контрагента склеены (D-2). Поэтому `unmapped_total` + # падает на число склеенных строк свода, получивших контрагента, а «1 + propagated» сверяется с записями в БД + # (design #79). + before_by_entries = {entry_id: row for row in rows for entry_id in row["entry_ids"]} + changed = { + tuple(row["entry_ids"]) for row in rows_after + if row["counterparty_id"] is not None and before_by_entries[row["entry_ids"][0]]["counterparty_id"] is None + } # fmt: skip + assert len(changed) >= 2 # noqa: PLR2004 - счета 60 и 62 + stored = ( + await seeded.execute( + select(OsvEntry).where(OsvEntry.owner_legal_entity_id == rb.id, OsvEntry.counterparty_name.is_not(None)) + .execution_options(populate_existing=True) + ) + ).scalars().all() # fmt: skip + named = [entry for entry in stored if _is_schastl(entry.counterparty_name)] + assert len(named) == 1 + body["propagated"] + assert {entry.counterparty_id for entry in named} == {schastl.id} + assert {entry.counterparty_match_kind for entry in named} == { + CounterpartyMatchKind.MANUAL, + CounterpartyMatchKind.PROPAGATED, + } + assert {entry.account_root for entry in named} >= {"60", "62", "66", "76"} # включая пары-пробелы + assert after["unmapped_total"] == unmapped_before - len(changed) + + same_name = [r for r in rows_after if r["legal_entity_id"] == str(rb.id) and _is_schastl(r["counterparty_name"])] + assert {r["account_root"] for r in same_name} >= {"60", "62"} # 66 и 76 РБ — пробелы, строк в своде нет + assert all(r["counterparty_id"] == str(schastl.id) and r["counterparty_type"] == "GSK" for r in same_name) + + # Счастливочная: контрагент «Русский бисквит» с ИНН — GSK по ИНН + inn_rows = [ + r + for r in rows_after + if r["legal_entity_id"] == str(schastl.id) + and r["counterparty_name"] == RB_NAME_IN_SCHASTL + and r["counterparty_inn"] == RB_INN + ] + assert inn_rows + assert all(r["counterparty_type"] == "GSK" and r["match_kind"] == "INN" for r in inn_rows) + + # сверка суммы периодов с golden: контрагент РБ по счёту 60, суммы Q1+Q2+Q3 считаются здесь, а не доменом + golden = _golden_rows(fixtures_dir, "rb", "60") + keys = {q: defaultdict(list) for q in golden} + for quarter, quarter_rows in golden.items(): + for row in quarter_rows: + keys[quarter][_key(row)].append(row) + common = [ + k for k in keys[Q1] if k in keys[Q2] and k in keys[Q3] and any(_dec(r["turnover_debit"]) for r in keys[Q2][k]) + ] + assert common + checked = 0 + for key in common[:5]: + account_code, name, inn = key + found = [ + r for r in rows_after + if r["legal_entity_id"] == str(rb.id) and r["account_code"] == account_code + and (r["counterparty_name"] or "").strip() == name and (r["counterparty_inn"] or "").replace(" ", "") == inn + ] # fmt: skip + assert len(found) == 1, key + for field in SUMS: + first_q, last_q = keys[Q1][key], keys[Q3][key] + if field.startswith("opening"): + expected = sum(_dec(r[field]) for r in first_q) + elif field.startswith("closing"): + expected = sum(_dec(r[field]) for r in last_q) + else: + expected = sum(_dec(r[field]) for q in (Q1, Q2, Q3) for r in keys[q][key]) + assert Decimal(found[0][field]) == expected, (key, field) + assert found[0]["is_cumulative"] is False and len(found[0]["entry_ids"]) >= 3 # noqa: PLR2004 + checked += 1 + assert checked > 0 + + # экспорт + export = await client.get(f"{version_url}/osv/export") + assert export.status_code == HTTP_OK, export.text + book = load_workbook(io.BytesIO(export.content)) + main = list(book[book.sheetnames[0]].iter_rows(values_only=True)) + assert len(main) - 1 == after["rows_total"] + + elapsed = time.perf_counter() - started + logger.info("сквозной сценарий РБ: {:.1f} с, строк свода {}", elapsed, len(rows_after)) + assert elapsed <= TIME_BUDGET_SECONDS + assert all(d.status is not DocumentStatus.NEEDS_CHECK for d in documents) diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 35392e4..8f307f1 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -34,6 +34,7 @@ from src.models.osv import OsvEntry from src.repositories.counterparties import CounterpartyIndex from src.services.result_mapping import map_file from tests.factories import make_legal_entity, make_stored_file +from tests.fixtures_helpers import golden_result GOLDEN_DIR = Path("golden") / "expected_osv_parse" EXPECTED_SNAPSHOTS = 82 # результатов ОСВ в fixtures (rb, schastl, synthetic) @@ -48,13 +49,6 @@ def _snapshots(fixtures_dir: Path) -> list[Path]: return sorted((fixtures_dir / GOLDEN_DIR).rglob("*.json")) -def _payload(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResult: - data = json.loads(path.read_text("utf-8")) - # у снимка нет job_id/file_id (подставляются) и версии сервиса (в контракте она обязательна) - data["service"] = {"version": "golden", **data["service"]} - return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)}) - - def _decimal(value: str | None) -> Decimal | None: return Decimal(value) if value is not None else None @@ -116,7 +110,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P job_status=RecognitionStatus.done, original_name=path.name, ) - result = _payload(path, job_id=job.id, file_id=file.id) + result = golden_result(path, job_id=job.id, file_id=file.id) started = time.perf_counter() await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=datetime.now(UTC)) mapping_seconds += time.perf_counter() - started diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 0573cae..ad9e753 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -252,9 +252,9 @@ - Сценарий: seed ГК «Русский бисквит» (СПб РФ) с ЮЛ АО РБ (ИНН 3528052718) и ООО «Пекарня Счастливочная» (ИНН из `fixtures/directories/ecp-export-russkiy-biskvit.xlsx`, см. `test_import_full_export`) и ГСК из них; маппинг golden `rb/2024-Q1..Q3/*.json` и `schastl/2024-Q1..Q3/*.json` через `map_file` (реальный `CounterpartyIndex`); подтверждение всех документов через `POST /documents/{id}/confirm` (`ka_rf`); `POST /consolidations` 2024 Q3 → `INITIATED` (подхват) → `POST …/lock` → `GET …/osv/summary`: строки есть по счетам 60 и 62 у обеих и 76 у Счастливочной; `gaps` содержат РБ 66/67/76 с `missing_quarters ⊇ [Q1, Q2]` (и Счастливочной 01/02); ячейка чек-листа ОСВ 58 РБ — не `CONFIRMED` (есть только 58.3); `GET …/osv?unmapped=true` содержит строки РБ «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (имя как в файле, без учёта регистра — найти по `normalize_alias`); `POST …/osv/entries/{id}/counterparty` → `GSK`, `propagated ≥ 1`; повторный `summary.unmapped_total` уменьшился на `1 + propagated`; все строки с этим именем во всех счетах РБ — `GSK`; строка Счастливочной «Русский бисквит» с ИНН — `GSK`, `match_kind = INN`; `GET …/osv/export` открывается `openpyxl`, число строк = `rows_total`. - Время теста ≤ 60 с; выводить в лог число строк свода. -- [ ] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). +- [x] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). - [ ] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). -- [ ] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»`. +- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 1ccdb30..44c748d 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -263,3 +263,5 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 76 | 2026-10-06 | Аудит `EXPORT` пишется после сборки книги в той же транзакции запроса: `filters = f.model_dump(mode="json", exclude_none=True)` (enum → значение, UUID → строка; пустые фильтры — `{}`), `rows` — число строк листа после фильтров; ошибка сборки откатывает и аудит; отказы (403/404) не аудируются | spec audit-trail: EXPORT — действие над версией; запись без выгруженного файла была бы ложной | при сбое после коммита (обрыв соединения при отдаче) событие остаётся, а файл клиент мог не получить; `model_dump(mode="json", exclude_none=True)` отличается от буквального `model_dump` плана (M-5): UUID и enum должны быть JSON-сериализуемы для `audit_event` | | 77 | 2026-10-06 | R-12: все текстовые ячейки экспорта (компания, ИНН, счёт, период, контрагент, ИНН контрагента, «Сопоставлено с», тип, способ, лист «Пробелы») проходят `_put_text`: запрещённые XML-символы (`ILLEGAL_CHARACTERS_RE`) вырезаются, тип ячейки принудительно `"s"`, поэтому `=`, `+`, `-`, `@` в начале не становятся формулой, а текст читается обратно без изменений | файлы внешние, аналитики открывают выгрузку в Excel: без этого `=HYPERLINK(...)` из чужого файла выполнился бы в их книге, а управляющий символ давал `IllegalCharacterError` → 500 | текст ячейки с ведущим `=` больше не вычисляется — это желаемо; вырезанный управляющий символ из выгрузки пропадает | | 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | +| 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | +| 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из spec consolidated-osv «Сопоставление из плана среза» верно только для нарастающих документов (одна запись = одна строка свода) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 2a1b473..e1a2326 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -59,6 +59,8 @@ - [ ] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и Счастливочной за Q1–Q3 2024 → подтверждение → консолидация 2024 Q3 → свод: счета 60/62 у обеих и 76 у Счастливочной, остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем; - экспорт открывается. + экспорт открывается. НЕ закрыт: тест написан и помечен `xfail(strict)` — на реальных данных нарастающий Q1 + (`is_cumulative = true`) не привязывается к версии Q3 (`fits`) и не считается квартальным Q1 (`select_sources`); + снять xfail после исправления. - [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md (новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9). -- 2.47.2 From d3c4964e9257c5ef4ae30038b4b053f382c58998 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:36:24 +0400 Subject: [PATCH 21/27] =?UTF-8?q?fix(consolidation):=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20=D0=B7=D0=B0=20Q1=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=BE=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=80=D0=B0=D1=81=D1=82=D0=B0=D1=8E=D1=89=D0=B5?= =?UTF-8?q?=D0=BC=D1=83=20=D0=B8=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20(R-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сквозной тест на фикстурах РБ показал: парсер помечает ОСВ за Q1 нарастающей, а привязка (`fits`) брала нарастающий только за квартал версии, `select_sources` не считал его квартальным Q1 — для версии Q3 привязано 17 из 22, пары 60/62(/76) в пробеле [Q1], свод пуст. - consolidation_periods.effective_cumulative: для Q1 — квартальный при любом флаге; одна функция для `fits`, ключа замены `_key`, `select_sources` (версии Q2+) и адаптера чек-листа; для версии Q1 поведение прежнее (№50) - тесты: таблица effective_cumulative, select_sources/build/чек-лист с нарастающим Q1, fits любой версии года, подтверждение и подхват (нарастающий и квартальный Q1 — один слот, побеждает поздний) - test_consolidation_fixtures: xfail снят, сценарий зелёный (22 привязки, 1385 строк) - спеки documents-api и consolidated-osv: фраза о равнозначности Q1; design #81; план Task 13 Step 2–4 и tasks.md 5.1 отмечены Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/checklist.py | 10 ++- api/src/domain/consolidation_periods.py | 27 +++++-- api/src/services/consolidation_documents.py | 15 ++-- api/tests/test_consolidation_documents.py | 75 +++++++++++++++++++ api/tests/test_consolidation_fixtures.py | 10 --- api/tests/test_domain_checklist.py | 19 +++++ api/tests/test_domain_consolidated_osv.py | 24 ++++++ .../test_domain_consolidation_periods.py | 51 +++++++++++++ .../plans/2026-10-06-slice-4-consolidation.md | 4 +- .../changes/slice-4-consolidation/design.md | 1 + .../specs/consolidated-osv/spec.md | 4 +- .../specs/documents-api/spec.md | 4 +- .../changes/slice-4-consolidation/tasks.md | 8 +- 13 files changed, 220 insertions(+), 32 deletions(-) diff --git a/api/src/domain/checklist.py b/api/src/domain/checklist.py index 1cd24cf..7cfc91d 100644 --- a/api/src/domain/checklist.py +++ b/api/src/domain/checklist.py @@ -23,7 +23,7 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Protocol, TypeVar -from src.domain.consolidation_periods import Sources, quarters_through, select_sources +from src.domain.consolidation_periods import Sources, effective_cumulative, quarters_through, select_sources from src.domain.osv_mapping import account_sort_key from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -130,14 +130,18 @@ def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLi Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке - кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46). + кварталов; документ за `Q1` — квартальный при любом флаге, R-14) и перечисляет `missing_quarters` только при + частичном покрытии: без единого документа — `()` (№46). """ selected = select_sources(version_quarter, documents) if isinstance(selected, Sources): return _Coverage(True, (), tuple(d.id for d in selected.documents)) needed = quarters_through(version_quarter) present = tuple( - d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter + d.id + for quarter in needed + for d in documents + if not effective_cumulative(d.is_cumulative, d.period_quarter) and d.period_quarter == quarter ) return _Coverage(False, selected.missing_quarters if present else (), present) diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py index 4948bed..6faf2aa 100644 --- a/api/src/domain/consolidation_periods.py +++ b/api/src/domain/consolidation_periods.py @@ -19,6 +19,7 @@ __all__ = [ "Gap", "Sources", "counts_as_cumulative", + "effective_cumulative", "quarters_through", "reporting_date", "select_sources", @@ -56,6 +57,17 @@ def counts_as_cumulative(is_cumulative: bool | None) -> bool: return is_cumulative is not False +def effective_cumulative(is_cumulative: bool | None, period_quarter: PeriodQuarter | None) -> bool: + """Нарастающий ли документ для привязки, ключа замены и выбора квартальных источников (R-14). + + Документ за `Q1` равнозначен и нарастающему, и квартальному (нарастающий с начала года за первый квартал и есть + квартал `Q1`; парсер помечает такие ОСВ нарастающими): здесь он считается квартальным при любом флаге — подходит + любой версии года, а нарастающий и квартальный `Q1` одной пары — один слот привязки. Прочие — + `counts_as_cumulative`. + """ + return period_quarter != PeriodQuarter.Q1 and counts_as_cumulative(is_cumulative) + + class DocLike(Protocol): """Документ-кандидат в источники (структурный тип): поля — как в DBML `document`.""" @@ -90,10 +102,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So """Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03). 1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные - рядом игнорируются (нарастающий полнее по определению). - 2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный - `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала — - порядок входа). + рядом игнорируются (нарастающий полнее по определению). Для версии `Q1` это нарастающий `Q1` «как есть». + 2. Иначе нужны квартальные за каждый квартал `Q1..Qn` версии (`effective_cumulative` ложен: `is_cumulative = False` + или любой документ за `Q1` — R-14, нарастающий `Q1` для версий `Q2+` служит квартальным `Q1`); для версии `Q1` + квартальный `Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри + квартала — порядок входа). 3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`. Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие @@ -106,7 +119,11 @@ def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> So if cumulative: return Sources(documents=cumulative, cumulative=True) needed = quarters_through(version_quarter) - quarterly = [d for d in documents if d.is_cumulative is False and d.period_quarter in needed] + quarterly = [ + d + for d in documents + if not effective_cumulative(d.is_cumulative, d.period_quarter) and d.period_quarter in needed + ] missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly)) if missing: return Gap(missing_quarters=missing) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index 301a110..f6bad2e 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -8,8 +8,9 @@ (`unbind_entity`). Версии вне «в работе» не трогаются. Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` -привязан один документ — с самым поздним `confirmed_at`; `cumulative = is_cumulative is not False` (NULL — ББ/ОФР — -нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют. +привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР — +нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`: +документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний. Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` (`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — @@ -27,7 +28,7 @@ from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession -from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative +from src.domain.consolidation_periods import QUARTER_ORDER, effective_cumulative from src.domain.policy import IN_WORK_STATUSES from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -56,8 +57,9 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий.""" - return counts_as_cumulative(document.is_cumulative) + """Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` — + NULL нарастающий; документ за `Q1` — квартальный при любом флаге.""" + return effective_cumulative(document.is_cumulative, document.period_quarter) def _key(document: Document) -> BindingKey: @@ -84,7 +86,8 @@ def fits( member_ids: Collection[uuid.UUID], ) -> bool: """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или - NULL) — квартал версии; квартальный — не позже квартала версии. Статус документа и файла не проверяется — это + NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом + флаге (R-14). Статус документа и файла не проверяется — это дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: return False diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index faa7ee5..3ec0e19 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -101,6 +101,10 @@ def _pure_document( (_pure_document(cumulative=False, quarter=None), False), # квартал не определён (_pure_document(cumulative=None, quarter=Q3), True), # ББ/ОФР: NULL — нарастающий (решение №10) (_pure_document(cumulative=None, quarter=Q2), False), # NULL за Q2 при версии Q3 — как нарастающий + (_pure_document(cumulative=True, quarter=Q1), True), # R-14: нарастающий Q1 = квартальный Q1, подходит Q3 + (_pure_document(cumulative=None, quarter=Q1), True), # R-14: флаг за Q1 не различает + (_pure_document(year=YEAR - 1, cumulative=True, quarter=Q1), False), # Q1 другого года + (_pure_document(entity=_STRANGER, cumulative=True, quarter=Q1), False), # Q1 не члена периметра ], ) def test_fits(document: Document, expected: bool) -> None: @@ -200,6 +204,16 @@ async def _bind(session: AsyncSession, version: ConsolidationVersion, document: await session.flush() +@pytest.mark.parametrize("version_quarter", [Q1, Q2, Q3, Q4]) +@pytest.mark.parametrize("cumulative", [True, False, None]) +def test_q1_document_fits_every_version_of_year(version_quarter: PeriodQuarter, cumulative: bool | None) -> None: + """R-14: документ за Q1 равнозначен нарастающему и квартальному — подходит любой версии года.""" + version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) + consolidation = Consolidation(year=YEAR, quarter=version_quarter) + + assert fits(_pure_document(cumulative=cumulative, quarter=Q1), version, consolidation, {_MEMBER}) is True + + # --- подтверждение ---------------------------------------------------------------------------------------------------- @@ -255,6 +269,41 @@ async def test_confirm_binds_in_other_in_work_statuses( assert len(await _history(seeded, version.id)) == before + (1 if moved else 0) +@pytest.mark.integration +async def test_confirm_binds_cumulative_q1_to_version_q3(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """R-14 (фикстуры РБ): нарастающая ОСВ за Q1 привязывается к версии Q3 как квартальный Q1 и двигает версию.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0], quarter=Q1, cumulative=True) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {document.id} + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +@pytest.mark.parametrize("bound_cumulative", [False, True]) +async def test_confirm_q1_replaces_bound_q1_of_other_flag( + seeded: AsyncSession, auth_client: AuthClientFactory, bound_cumulative: bool +) -> None: + """R-14: нарастающий и квартальный Q1 одной пары (ЮЛ, счёт) — один слот привязки: подтверждённый позже вытесняет + привязанный.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities, status=ConsolidationStatus.DATA_CORRECTION) + old = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=bound_cumulative) + await _bind(seeded, version, old) + new = await _document(seeded, entities[0], quarter=Q1, cumulative=not bound_cumulative) + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{new.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == {new.id} + + @pytest.mark.integration async def test_confirm_does_not_bind_cumulative_of_earlier_quarter( seeded: AsyncSession, auth_client: AuthClientFactory @@ -487,6 +536,32 @@ async def test_bind_confirmed_replaces_older_binding_of_same_key(seeded: AsyncSe assert await _bound(seeded, version.id) == {newer.id} +@pytest.mark.integration +@pytest.mark.parametrize("later_cumulative", [True, False]) +async def test_bind_confirmed_q1_of_both_flags_is_one_slot(seeded: AsyncSession, later_cumulative: bool) -> None: + """R-14: подхват из нарастающего и квартального Q1 одной пары берёт один — с более поздним `confirmed_at`.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + consolidation = await seeded.get(Consolidation, version.consolidation_id) + assert consolidation is not None + now = datetime.datetime.now(datetime.UTC) + later = await _confirmed(seeded, entities[0], quarter=Q1, cumulative=later_cumulative, confirmed_at=now) + await _confirmed( + seeded, + entities[0], + quarter=Q1, + cumulative=not later_cumulative, + confirmed_at=now - datetime.timedelta(hours=1), + ) + + count = await consolidation_documents.bind_confirmed( + seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id] + ) + + assert count == 1 + assert await _bound(seeded, version.id) == {later.id} + + @pytest.mark.integration async def test_bind_confirmed_tie_on_confirmed_at_takes_larger_id(seeded: AsyncSession) -> None: """Решение №35: при равном `confirmed_at` привязывается документ с большим `id`.""" diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py index 26e677f..daede18 100644 --- a/api/tests/test_consolidation_fixtures.py +++ b/api/tests/test_consolidation_fixtures.py @@ -101,16 +101,6 @@ async def _all_rows(client: AsyncClient, version_url: str, **params: Any) -> lis return items -@pytest.mark.xfail( - strict=True, - reason=( - "ДЕФЕКТ Срезов 4 (D-5/D-2): golden ОСВ Q1 помечены is_cumulative=true (парсер: первый квартал нарастающий), а " - "`consolidation_documents.fits` привязывает нарастающий документ только за квартал версии, и " - "`consolidation_periods.select_sources` не считает нарастающий Q1 квартальным — для версии Q3 документы Q1 не " - "привязаны (17 из 22), пары 60/62/76 уходят в gaps [Q1], rows_total = 0. Снять xfail после исправления: " - "нарастающий Q1 равнозначен квартальному Q1 для любой версии года (fits + select_sources)" - ), -) async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0914, PLR0915 - сквозной сценарий в одном месте auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path ) -> None: diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py index 57c8ec6..bbd4913 100644 --- a/api/tests/test_domain_checklist.py +++ b/api/tests/test_domain_checklist.py @@ -99,6 +99,25 @@ def test_cumulative_of_other_quarter_does_not_cover() -> None: assert cell == Cell(CellStatus.MISSING) +def test_cumulative_q1_with_quarterly_q2_q3_confirms() -> None: + """R-14: нарастающий Q1 (так парсер помечает первый квартал) равнозначен квартальному Q1 для версии Q3.""" + entity = _entity() + documents = [_doc(entity, quarter=Q1, cumulative=True), _doc(entity, quarter=Q2), _doc(entity, quarter=Q3)] + + cell = _cell(OSV_60, entity, bound=documents) + + assert cell == Cell(CellStatus.CONFIRMED, (), tuple(d.id for d in documents)) + + +def test_cumulative_q1_counts_as_present_quarter_of_gap() -> None: + entity = _entity() + first, third = _doc(entity, quarter=Q1, cumulative=True), _doc(entity, quarter=Q3) + + cell = _cell(OSV_60, entity, bound=[third, first]) + + assert cell == Cell(CellStatus.MISSING, (Q2,), (first.id, third.id)) + + def test_q1_version_quarterly_q1_equals_cumulative() -> None: entity = _entity() document = _doc(entity, quarter=Q1, cumulative=False) diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index f2ed683..8e34e25 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -206,6 +206,30 @@ def test_cumulative_next_to_quarterly_rows_as_is() -> None: assert result.gaps == () +def test_cumulative_q1_with_quarterly_q2_q3_is_glued() -> None: + """R-14, форма фикстур РБ: ОСВ Q1 помечена нарастающей, Q2/Q3 — квартальные; версия Q3 — склейка: opening из Q1, + turnover = Σ, closing из Q3, строка квартальная; пробела нет.""" + q1, q2, q3 = _doc(quarter=Q1, cumulative=True), _doc(quarter=Q2), _doc(quarter=Q3) + r1 = _row(q1, od="100", oc="5", td="200", tc="7", cd="300", cc="12") + r2 = _row(q2, od="300", oc="12", td="150", tc=None, cd="450", cc="12") + r3 = _row(q3, od="450", oc="12", td="50", tc="3", cd="500", cc="15") + result = _build([q1, q2, q3], [r3, r1, r2]) + assert result.gaps == () + assert len(result.rows) == 1 + row = result.rows[0] + assert _sums(row) == (Decimal(100), Decimal(5), Decimal(400), Decimal(10), Decimal(500), Decimal(15)) + assert row.is_cumulative is False + assert row.entry_ids == (r1.id, r2.id, r3.id) + + +def test_cumulative_q1_at_version_q1_is_taken_as_is() -> None: + q1 = _doc(quarter=Q1, cumulative=True) + source = _row(q1, od="1", oc=None, td="2", tc=None, cd="3", cc=None) + row = _build([q1], [source], quarter=Q1).rows[0] + assert row.is_cumulative is True + assert _sums(row) == (Decimal(1), None, Decimal(2), None, Decimal(3), None) + + def test_cumulative_document_keeps_duplicate_keys_separate() -> None: """Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6).""" cumulative = _doc(cumulative=True) diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py index 501af6a..014040b 100644 --- a/api/tests/test_domain_consolidation_periods.py +++ b/api/tests/test_domain_consolidation_periods.py @@ -11,6 +11,7 @@ from src.domain.consolidation_periods import ( Gap, Sources, counts_as_cumulative, + effective_cumulative, quarters_through, reporting_date, select_sources, @@ -115,3 +116,53 @@ def test_quarterly_after_version_and_without_quarter_are_ignored() -> None: def test_no_documents_is_gap_of_all_quarters() -> None: assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3)) assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,)) + + +# ------------------------------------------------------------------------- R-14: документ за Q1 равнозначен обоим видам + + +@pytest.mark.parametrize( + ("flag", "quarter", "expected"), + [ + (True, Q1, False), # нарастающий с начала года за Q1 — это и есть квартал Q1 + (None, Q1, False), + (False, Q1, False), + (True, Q2, True), + (None, Q3, True), # ББ/ОФР вне Q1 — нарастающие (решение №10) + (False, Q3, False), + (True, None, True), # квартал не определён — по флагу (такой документ не привязывается) + ], +) +def test_effective_cumulative(flag: bool | None, quarter: PeriodQuarter | None, expected: bool) -> None: + assert effective_cumulative(flag, quarter) is expected + + +def test_cumulative_q1_is_quarterly_q1_source_for_later_version() -> None: + """Фикстуры РБ: парсер помечает ОСВ за Q1 нарастающей, Q2/Q3 — квартальными; для версии Q3 это полный комплект.""" + q1, q2, q3 = _doc(Q1, True), _doc(Q2), _doc(Q3) + assert select_sources(Q3, [q3, q2, q1]) == Sources(documents=(q1, q2, q3), cumulative=False) + assert select_sources(Q2, [q2, q1]) == Sources(documents=(q1, q2), cumulative=False) + + +def test_null_flag_q1_is_quarterly_q1_source_for_later_version() -> None: + q1, q2 = _doc(Q1, None), _doc(Q2) + assert select_sources(Q2, [q1, q2]) == Sources(documents=(q1, q2), cumulative=False) + + +def test_cumulative_q1_does_not_fill_other_quarters() -> None: + assert select_sources(Q2, [_doc(Q1, True)]) == Gap(missing_quarters=(Q2,)) + # нарастающий Q2 при версии Q3 — не квартальный Q2: не хватает квартальных Q2 и Q3 + assert select_sources(Q3, [_doc(Q1, True), _doc(Q2, True)]) == Gap(missing_quarters=(Q2, Q3)) + + +def test_cumulative_of_version_quarter_still_beats_cumulative_q1() -> None: + cumulative = _doc(Q3, True) + assert select_sources(Q3, [_doc(Q1, True), _doc(Q2), _doc(Q3), cumulative]) == Sources((cumulative,), True) + + +def test_q1_version_cumulative_q1_is_taken_as_is() -> None: + """Версия Q1: нарастающий Q1 — источник «как есть» (решение №50), квартальный — склейка из одного квартала.""" + cumulative, quarterly = _doc(Q1, True), _doc(Q1, False) + assert select_sources(Q1, [cumulative]) == Sources((cumulative,), cumulative=True) + assert select_sources(Q1, [quarterly, cumulative]) == Sources((cumulative,), cumulative=True) + assert select_sources(Q1, [quarterly]) == Sources((quarterly,), cumulative=False) diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index ad9e753..52c1d07 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -253,8 +253,8 @@ - Время теста ≤ 60 с; выводить в лог число строк свода. - [x] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). -- [ ] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). -- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления). +- [x] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). +- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления; закрыты исправлением R-14 — документ за Q1 равнозначен нарастающему и квартальному, xfail снят). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 44c748d..db5af43 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -265,3 +265,4 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | | 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из spec consolidated-osv «Сопоставление из плана среза» верно только для нарастающих документов (одна запись = одна строка свода) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | +| 81 | 2026-10-06 | R-14: документ за `Q1` (`period_quarter = Q1`) равнозначен и нарастающему, и квартальному. Одна функция `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `fits` (документ за `Q1` подходит любой версии года при любом флаге, прочие — как раньше), в ключе замены `_key` (нарастающий и квартальный `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`), в `select_sources` (для версии `Q2+` документ за `Q1` — квартальный источник `Q1`; нарастающий за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающий «как есть», квартальный — склейка, №50) и в адаптере чек-листа | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающий `Q1` и квартальный `Q1` одной пары вытесняют друг друга при привязке; правило распространяется и на ББ/ОФР за `Q1` (`is_cumulative` NULL): они тоже привязываются к версиям `Q2`–`Q4` и подтверждение такого документа двигает версию в `DATA_CORRECTION` (ячейки ББ/ОФР чек-листа не затронуты — они требуют квартала версии, №49); если владелец хочет иное — меняется только `effective_cumulative` | diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md index cc3545e..2b5bd01 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -12,7 +12,9 @@ Для каждой пары (ЮЛ периметра, `osv_account_code` документа) ядро SHALL выбирать источники среди ОСВ-документов, привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала -версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; при наличии и +версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` +документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть +квартал `Q1`, R-14); при наличии и нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки SHALL склеиваться по ключу (`account_code` строки, `counterparty_name.strip()`, ИНН без пробелов): `opening_*` из diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md index 5e2f6e4..90af9ee 100644 --- a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -12,7 +12,9 @@ В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, `period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал -версии; false → `period_quarter` ≤ квартала версии). Привязанный ранее документ того же ЮЛ с той же тройкой +версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14): +он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) — +одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой (`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index e1a2326..7559995 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -56,11 +56,11 @@ ## 5. Сквозной тест и документы -- [ ] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и +- [x] 5.1 **Фикстуры РБ.** `tests/test_consolidation_fixtures.py` (маркер `fixtures`): маппинг golden-снимков ОСВ РБ и Счастливочной за Q1–Q3 2024 → подтверждение → консолидация 2024 Q3 → свод: счета 60/62 у обеих и 76 у Счастливочной, остальные в `gaps`; одно сопоставление «Пекарня Счастливочная ООО» → `GSK` у всех строк РБ с этим именем; - экспорт открывается. НЕ закрыт: тест написан и помечен `xfail(strict)` — на реальных данных нарастающий Q1 - (`is_cumulative = true`) не привязывается к версии Q3 (`fits`) и не считается квартальным Q1 (`select_sources`); - снять xfail после исправления. + экспорт открывается. Найденный тестом дефект (нарастающий Q1 не привязывался к версии Q3 и не считался + квартальным Q1) исправлен рулингом R-14 (design #81): документ за Q1 равнозначен нарастающему и квартальному; + xfail снят. - [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md (новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9). -- 2.47.2 From 0bfe9eea5f094462726d12bde5f99453b8458fd3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:44:18 +0400 Subject: [PATCH 22/27] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D0=B7=D0=BD=D0=B0=D1=87=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20Q1=20=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=9E=D0=A1=D0=92,=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0=20=D0=B7=D0=B0=20Q1=20=D0=BA=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F=D0=BC=20Q2=E2=80=93Q4=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D1=8B=D0=B2=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20(R-14a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ББ/ОФР за Q1 — отчёт на конец квартала, не источник версий Q2–Q4. В `consolidation_documents._cumulative` равнозначность Q1 (`effective_cumulative`) применяется только к `document_type = OSV`; для ББ/ОФР — прежнее правило D-5 (NULL — нарастающий, только квартал версии), ключ замены не меняется. - тесты: ББ/ОФР за Q1 подходят только версии Q1; ключ замены ББ/ОФР всегда с признаком нарастающего, ОСВ за Q1 — без различия флага; подтверждение ББ/ОФР за Q1 при версии Q3 — без привязки и перехода; ячейка ББ чек-листа Q3 не закрывается ББ Q1 - спека documents-api: ключ замены R-1 с признаком нарастающего (вместо устаревшей «тройки»), равнозначность Q1 — только для ОСВ; consolidated-osv — «ОСВ за Q1» - design #81: R-14 + R-14a Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/services/consolidation_documents.py | 22 ++++--- api/tests/test_consolidation_documents.py | 63 +++++++++++++++++-- api/tests/test_domain_checklist.py | 9 +++ .../changes/slice-4-consolidation/design.md | 2 +- .../specs/consolidated-osv/spec.md | 2 +- .../specs/documents-api/spec.md | 9 +-- 6 files changed, 88 insertions(+), 19 deletions(-) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index f6bad2e..51098b0 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -8,9 +8,10 @@ (`unbind_entity`). Версии вне «в работе» не трогаются. Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)` -привязан один документ — с самым поздним `confirmed_at`; `cumulative = effective_cumulative(...)` (NULL — ББ/ОФР — -нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме `Q1`: -документ за `Q1` равнозначен обоим видам (R-14), нарастающий и квартальный `Q1` — один слот, побеждает поздний. +привязан один документ — с самым поздним `confirmed_at`; `cumulative = _cumulative(document)` (NULL — ББ/ОФР — +нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме ОСВ за +`Q1`: она равнозначна обоим видам (R-14), нарастающая и квартальная `Q1` — один слот, побеждает поздняя; ББ/ОФР +равнозначность не касается (R-14a). Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id` (`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии — @@ -28,7 +29,7 @@ from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession -from src.domain.consolidation_periods import QUARTER_ORDER, effective_cumulative +from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative, effective_cumulative from src.domain.policy import IN_WORK_STATUSES from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter @@ -57,9 +58,12 @@ def _rowcount(result: Any) -> int: def _cumulative(document: Document) -> bool: - """Нарастающий ли документ для привязки (R-1, решение №10, R-14): `consolidation_periods.effective_cumulative` — - NULL нарастающий; документ за `Q1` — квартальный при любом флаге.""" - return effective_cumulative(document.is_cumulative, document.period_quarter) + """Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий; + ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР + за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие).""" + if document.document_type == DocumentType.OSV: + return effective_cumulative(document.is_cumulative, document.period_quarter) + return counts_as_cumulative(document.is_cumulative) def _key(document: Document) -> BindingKey: @@ -86,8 +90,8 @@ def fits( member_ids: Collection[uuid.UUID], ) -> bool: """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или - NULL) — квартал версии; квартальный — не позже квартала версии; документ за `Q1` — любой версии года при любом - флаге (R-14). Статус документа и файла не проверяется — это + NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге + (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: return False diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index 3ec0e19..cde20a7 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -76,12 +76,17 @@ _STRANGER = uuid.uuid4() def _pure_document( - *, entity: uuid.UUID = _MEMBER, year: int = YEAR, quarter: PeriodQuarter | None = Q3, cumulative: bool | None + *, + entity: uuid.UUID = _MEMBER, + year: int = YEAR, + quarter: PeriodQuarter | None = Q3, + cumulative: bool | None, + kind: DocumentType = DocumentType.OSV, ) -> Document: return Document( legal_entity_id=entity, - document_type=DocumentType.OSV, - osv_account_code=ACCOUNT, + document_type=kind, + osv_account_code=ACCOUNT if kind == DocumentType.OSV else None, period_year=year, period_quarter=quarter, is_cumulative=cumulative, @@ -207,13 +212,43 @@ async def _bind(session: AsyncSession, version: ConsolidationVersion, document: @pytest.mark.parametrize("version_quarter", [Q1, Q2, Q3, Q4]) @pytest.mark.parametrize("cumulative", [True, False, None]) def test_q1_document_fits_every_version_of_year(version_quarter: PeriodQuarter, cumulative: bool | None) -> None: - """R-14: документ за Q1 равнозначен нарастающему и квартальному — подходит любой версии года.""" + """R-14: ОСВ за Q1 равнозначна нарастающей и квартальной — подходит любой версии года.""" version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) consolidation = Consolidation(year=YEAR, quarter=version_quarter) assert fits(_pure_document(cumulative=cumulative, quarter=Q1), version, consolidation, {_MEMBER}) is True +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +@pytest.mark.parametrize(("version_quarter", "expected"), [(Q1, True), (Q2, False), (Q3, False), (Q4, False)]) +def test_balance_and_income_of_q1_fit_only_q1_version( + kind: DocumentType, version_quarter: PeriodQuarter, expected: bool +) -> None: + """R-14a: ББ/ОФР за Q1 — отчёт на конец Q1, не источник версий Q2–Q4 (NULL — нарастающий, D-5 без изменений).""" + version = ConsolidationVersion(status=ConsolidationStatus.INITIATED) + consolidation = Consolidation(year=YEAR, quarter=version_quarter) + document = _pure_document(cumulative=None, quarter=Q1, kind=kind) + + assert fits(document, version, consolidation, {_MEMBER}) is expected + + +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +@pytest.mark.parametrize("quarter", [Q1, Q2, Q3, Q4]) +def test_binding_key_of_balance_and_income_is_cumulative(kind: DocumentType, quarter: PeriodQuarter) -> None: + """R-14a: равнозначность Q1 ключ замены ББ/ОФР не меняет: признак нарастающего у них всегда `True` (№10).""" + document = _pure_document(cumulative=None, quarter=quarter, kind=kind) + + assert consolidation_documents._key(document)[-1] is True # noqa: SLF001 - ключ замены R-1 — внутренний + + +@pytest.mark.parametrize("cumulative", [True, False]) +def test_binding_key_of_osv_q1_ignores_flag(cumulative: bool) -> None: + """R-14: нарастающая и квартальная ОСВ за Q1 одной пары — один слот привязки.""" + document = _pure_document(cumulative=cumulative, quarter=Q1) + + assert consolidation_documents._key(document)[-1] is False # noqa: SLF001 - ключ замены R-1 — внутренний + + # --- подтверждение ---------------------------------------------------------------------------------------------------- @@ -284,6 +319,26 @@ async def test_confirm_binds_cumulative_q1_to_version_q3(seeded: AsyncSession, a assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION +@pytest.mark.integration +@pytest.mark.parametrize("kind", [DocumentType.BALANCE, DocumentType.INCOME]) +async def test_confirm_does_not_bind_balance_or_income_of_q1_to_version_q3( + seeded: AsyncSession, auth_client: AuthClientFactory, kind: DocumentType +) -> None: + """R-14a: ББ/ОФР за Q1 к версии Q3 не привязываются и статус версии не двигают.""" + group, entities = await _group(seeded) + version = await _version(seeded, group, entities) + document = await _document(seeded, entities[0], quarter=Q1) + document.document_type, document.osv_account_code, document.is_cumulative = kind, None, None + await seeded.flush() + client = await auth_client(KA_RF) + + response = await client.post(f"/documents/{document.id}/confirm") + + assert response.status_code == HTTP_OK, response.text + assert await _bound(seeded, version.id) == set() + assert await _status(seeded, version.id) is ConsolidationStatus.INITIATED + + @pytest.mark.integration @pytest.mark.parametrize("bound_cumulative", [False, True]) async def test_confirm_q1_replaces_bound_q1_of_other_flag( diff --git a/api/tests/test_domain_checklist.py b/api/tests/test_domain_checklist.py index bbd4913..ff4cd93 100644 --- a/api/tests/test_domain_checklist.py +++ b/api/tests/test_domain_checklist.py @@ -226,6 +226,15 @@ def test_balance_of_q2_at_version_q3_is_missing() -> None: assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) +@pytest.mark.parametrize("cumulative", [None, True, False]) +def test_balance_of_q1_at_version_q3_is_missing(cumulative: bool | None) -> None: + """R-14a: равнозначность Q1 касается только ОСВ — ББ за Q1 ячейку ББ версии Q3 не закрывает.""" + entity = _entity() + balance = _doc(entity, kind=DocumentType.BALANCE, account=None, quarter=Q1, cumulative=cumulative) + + assert _cell(BALANCE, entity, bound=[balance]) == Cell(CellStatus.MISSING) + + def test_documents_of_other_entity_are_ignored() -> None: entity, stranger = _entity(), _entity() foreign = _doc(stranger, quarter=Q3, cumulative=True) diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index db5af43..adf0362 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -265,4 +265,4 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | | 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из spec consolidated-osv «Сопоставление из плана среза» верно только для нарастающих документов (одна запись = одна строка свода) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | -| 81 | 2026-10-06 | R-14: документ за `Q1` (`period_quarter = Q1`) равнозначен и нарастающему, и квартальному. Одна функция `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `fits` (документ за `Q1` подходит любой версии года при любом флаге, прочие — как раньше), в ключе замены `_key` (нарастающий и квартальный `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`), в `select_sources` (для версии `Q2+` документ за `Q1` — квартальный источник `Q1`; нарастающий за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающий «как есть», квартальный — склейка, №50) и в адаптере чек-листа | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающий `Q1` и квартальный `Q1` одной пары вытесняют друг друга при привязке; правило распространяется и на ББ/ОФР за `Q1` (`is_cumulative` NULL): они тоже привязываются к версиям `Q2`–`Q4` и подтверждение такого документа двигает версию в `DATA_CORRECTION` (ячейки ББ/ОФР чек-листа не затронуты — они требуют квартала версии, №49); если владелец хочет иное — меняется только `effective_cumulative` | +| 81 | 2026-10-06 | R-14 + R-14a: ОСВ за `Q1` (`period_quarter = Q1`) равнозначна и нарастающей, и квартальной. `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `select_sources` (для версии `Q2+` ОСВ за `Q1` — квартальный источник `Q1`; нарастающая за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающая «как есть», квартальная — склейка, №50), в адаптере чек-листа и — только для `document_type = OSV` — в `consolidation_documents._cumulative`, то есть в `fits` (ОСВ за `Q1` подходит любой версии года при любом флаге) и в ключе замены `_key` (нарастающая и квартальная ОСВ за `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`). R-14a: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4`: для них прежнее правило D-5 (NULL → нарастающий → только квартал версии), ключ замены не меняется | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающая и квартальная ОСВ за `Q1` одной пары вытесняют друг друга при привязке; если и ББ/ОФР за `Q1` должны подхватываться версиями `Q2`–`Q4` — снять условие `document_type = OSV` в `_cumulative` | diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md index 2b5bd01..88e67de 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -13,7 +13,7 @@ привязанных к версии (`consolidation_document`): документ с `is_cumulative = true` и `period_quarter` = квартал версии → единственный источник; иначе квартальные документы (`is_cumulative = false`) за каждый квартал от `Q1` до квартала версии включительно; для версии за `Q1` квартальный документ `Q1` равнозначен нарастающему; для версии за `Q2`–`Q4` -документ за `Q1` с любым `is_cumulative` служит квартальным `Q1` (нарастающий с начала года за первый квартал и есть +ОСВ за `Q1` с любым `is_cumulative` служит квартальной `Q1` (нарастающий с начала года за первый квартал и есть квартал `Q1`, R-14); при наличии и нарастающего, и квартальных — нарастающий. Если какого-то квартала нет, пара SHALL попадать в `gaps` с `missing_quarters`, а строки по ней MUST NOT строиться (решение владельца 06.10.2026, OQ-03). Квартальные строки diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md index 90af9ee..a72f61d 100644 --- a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -12,10 +12,11 @@ В той же транзакции документ SHALL привязываться (`consolidation_document`) ко всем версиям «в работе» (`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`), которым он подходит: ЮЛ документа ∈ `consolidation_member` версии, `period_year` = год версии, `period_quarter` не NULL и (`is_cumulative` true или NULL → `period_quarter` = квартал -версии; false → `period_quarter` ≤ квартала версии). Документ за `Q1` равнозначен нарастающему и квартальному (R-14): -он подходит любой версии года при любом `is_cumulative`, а нарастающий и квартальный `Q1` одной пары (ЮЛ, счёт) — -одна привязка (подтверждённый позже вытесняет прежний). Привязанный ранее документ того же ЮЛ с той же тройкой -(`document_type`, `osv_account_code`, `period_quarter`) в той же версии SHALL отвязываться. Каждая версия, получившая +версии; false → `period_quarter` ≤ квартала версии). ОСВ за `Q1` равнозначна нарастающей и квартальной (R-14): она +подходит любой версии года при любом `is_cumulative`; ББ/ОФР за `Q1` — отчёт на конец квартала и подходят только версии +за `Q1` (R-14a). Привязанный ранее документ того же ЮЛ с тем же ключом (`document_type`, `osv_account_code`, +`period_quarter`, признак нарастающего: `is_cumulative` true или NULL; для ОСВ за `Q1` признак не различается) в той же +версии SHALL отвязываться (R-1). Каждая версия, получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` (`changed_by` = подтвердивший). Блокировка версии (`consolidation_lock`) для подтверждения MUST NOT требоваться. -- 2.47.2 From a5cde2adb79cf92a67fa8e3c9354aff180ebea32 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 07:52:25 +0400 Subject: [PATCH 23/27] =?UTF-8?q?test(consolidation):=20=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=81=D0=BA=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=BC=20=D1=82=D0=B5=D1=81=D1=82=D0=B5=20?= =?UTF-8?q?=D0=A0=D0=91,=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Точные числа свода и набор пробелов, кандидаты, MANUAL/alias_created, EXTERNAL до сопоставления; формулировка сценария «Сопоставление из плана среза» и design #80 уточнены; docstring effective_cumulative. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/domain/consolidation_periods.py | 3 ++ api/tests/test_consolidation_fixtures.py | 46 +++++++++++++------ .../plans/2026-10-06-slice-4-consolidation.md | 2 +- .../changes/slice-4-consolidation/design.md | 2 +- .../specs/consolidated-osv/spec.md | 4 +- 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py index 6faf2aa..b5a2549 100644 --- a/api/src/domain/consolidation_periods.py +++ b/api/src/domain/consolidation_periods.py @@ -64,6 +64,9 @@ def effective_cumulative(is_cumulative: bool | None, period_quarter: PeriodQuart квартал `Q1`; парсер помечает такие ОСВ нарастающими): здесь он считается квартальным при любом флаге — подходит любой версии года, а нарастающий и квартальный `Q1` одной пары — один слот привязки. Прочие — `counts_as_cumulative`. + + Не зависит от типа документа: ограничение «только ОСВ» (R-14a) живёт у вызывающего — + `consolidation_documents._cumulative`; для ББ/ОФР эту функцию вызывать нельзя. """ return period_quarter != PeriodQuarter.Q1 and counts_as_cumulative(is_cumulative) diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py index daede18..0262b77 100644 --- a/api/tests/test_consolidation_fixtures.py +++ b/api/tests/test_consolidation_fixtures.py @@ -1,6 +1,5 @@ """Сквозной сценарий Среза 4 на реальных данных группы «Русский бисквит» (tasks 5.1; spec consolidated-osv «Признаки на -фикстурах РБ», «Сопоставление из плана среза», «Рекомендуемая Счастливочная»; documents-api «Фикстуры, подтверждённые -до консолидации»). +фикстурах РБ», «Сопоставление из плана среза»; documents-api «Фикстуры, подтверждённые до консолидации»). Golden-снимки парсера `rb/2024-Q1..Q3` и `schastl/2024-Q1..Q3` → боевой `map_file` → подтверждение через `POST /documents/{id}/confirm` → создание консолидации 2024 Q3 (подхват подтверждённых) → блокировка → сводная ОСВ, @@ -49,6 +48,8 @@ SCHASTL_FILE_NAME = "ПЕКАРНЯ СЧАСТЛИВОЧНАЯ" # в файла RB_NAME_IN_SCHASTL = "Русский бисквит" TIME_BUDGET_SECONDS = 60.0 Q1, Q2, Q3 = "Q1", "Q2", "Q3" +ROWS_TOTAL, UNMAPPED_TOTAL = 1385, 1381 # свод РБ 2024 Q3 +CHANGED_ROWS, PROPAGATED = 4, 17 # строк свода, получивших контрагента; записей osv_entry, кроме выбранной (18 всего) SUMS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") @@ -119,6 +120,7 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 for path in _golden_files(fixtures_dir, folder): documents.extend(await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=user.id)) assert len(documents) == 11 + 11 # РБ: Q1 2, Q2 2, Q3 7; Счастливочная: Q1 3, Q2 3, Q3 5 + assert all(d.status is not DocumentStatus.NEEDS_CHECK for d in documents) # подтверждаются без правок client = await auth_client(KA_RF) for document in documents: @@ -155,7 +157,9 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 print( f"\nSVOD: rows={len(rows)} summary={ {k: v for k, v in summary.items() if k != 'gaps'} } gaps={summary['gaps']}" ) # noqa: T201 - assert summary["rows_total"] == len(rows) + # Числа сверены независимым подсчётом по сырым golden-JSON (склейка Q1–Q3, пробелы по наличию файлов). + assert summary["rows_total"] == len(rows) == ROWS_TOTAL + assert summary["unmapped_total"] == UNMAPPED_TOTAL # строки свода: 60 и 62 у обеих, 76 у Счастливочной present = {(row["legal_entity_id"], row["account_root"]) for row in rows} @@ -164,12 +168,13 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 assert (str(schastl.id), account) in present assert (str(schastl.id), "76") in present - # пробелы: у РБ нет квартальных ОСВ 66/67/76 за Q1–Q2, у Счастливочной — 01/02 + # пробелы (точный набор по файлам golden): у РБ за Q1–Q2 нет ОСВ 01, 58.03, 66, 67, 76, у Счастливочной — 01, 02 gaps = {(g["legal_entity_id"], g["account_code"]): g["missing_quarters"] for g in summary["gaps"]} - for account in ("66", "67", "76"): - assert set(gaps[(str(rb.id), account)]) >= {Q1, Q2} - for account in ("01", "02"): - assert set(gaps[(str(schastl.id), account)]) >= {Q1, Q2} + assert len(summary["gaps"]) == len(gaps) + assert gaps == { + **{(str(rb.id), account): [Q1, Q2] for account in ("01", "58.03", "66", "67", "76")}, + **{(str(schastl.id), account): [Q1, Q2] for account in ("01", "02")}, + } # чек-лист: ОСВ 58 РБ не подтверждена (есть только 58.03) checklist = await _get(client, f"{version_url}/checklist") @@ -189,28 +194,40 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 target = [row for row in unmapped if _is_schastl(row["counterparty_name"])] assert target first = target[0] + # записи склеенной строки идут по кварталам; сопоставляем запись последнего квартала: в строке свода показывается + # способ сопоставления записи самого позднего квартала (D-3, решение №8), поэтому только она даёт `MANUAL` + entry_id = first["entry_ids"][-1] unmapped_before = summary["unmapped_total"] + assert (first["counterparty_id"], first["counterparty_type"]) == (None, "EXTERNAL") + + # кандидаты: ООО «Пекарня Счастливочная» есть в списке с признаком GSK. `recommended` на реальном имени «Юг-Финанс / + # ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» не ожидается: рекомендация — точное совпадение нормализованного имени/алиаса (сценарий + # «Рекомендуемая Счастливочная» покрыт в test_api_counterparty_mapping на имени «Пекарня Счастливочная ООО») + candidates = await _get(client, f"{version_url}/counterparty-candidates", entry_id=entry_id) + schastl_candidate = next(item for item in candidates if item["id"] == str(schastl.id)) + assert schastl_candidate["counterparty_type"] == "GSK" mapped = await client.post( - f"{version_url}/osv/entries/{first['entry_ids'][0]}/counterparty", json={"legal_entity_id": str(schastl.id)} + f"{version_url}/osv/entries/{entry_id}/counterparty", json={"legal_entity_id": str(schastl.id)} ) assert mapped.status_code == HTTP_OK, mapped.text body = mapped.json() assert body["row"]["counterparty_type"] == "GSK" - assert body["propagated"] >= 1 + assert (body["row"]["match_kind"], body["alias_created"]) == ("MANUAL", True) + assert body["propagated"] == PROPAGATED after = await _get(client, f"{version_url}/osv/summary") rows_after = await _all_rows(client, version_url) # `propagated` считает записи `osv_entry` (в том числе в парах-пробелах 66/76, строк в своде не дающих), а # `unmapped_total` — строки свода: квартальные записи одного контрагента склеены (D-2). Поэтому `unmapped_total` # падает на число склеенных строк свода, получивших контрагента, а «1 + propagated» сверяется с записями в БД - # (design #79). - before_by_entries = {entry_id: row for row in rows for entry_id in row["entry_ids"]} + # (design #80). + before_by_entries = {eid: row for row in rows for eid in row["entry_ids"]} changed = { tuple(row["entry_ids"]) for row in rows_after if row["counterparty_id"] is not None and before_by_entries[row["entry_ids"][0]]["counterparty_id"] is None } # fmt: skip - assert len(changed) >= 2 # noqa: PLR2004 - счета 60 и 62 + assert len(changed) == CHANGED_ROWS stored = ( await seeded.execute( select(OsvEntry).where(OsvEntry.owner_legal_entity_id == rb.id, OsvEntry.counterparty_name.is_not(None)) @@ -218,7 +235,7 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 ) ).scalars().all() # fmt: skip named = [entry for entry in stored if _is_schastl(entry.counterparty_name)] - assert len(named) == 1 + body["propagated"] + assert len(named) == 1 + PROPAGATED == 1 + body["propagated"] assert {entry.counterparty_id for entry in named} == {schastl.id} assert {entry.counterparty_match_kind for entry in named} == { CounterpartyMatchKind.MANUAL, @@ -284,4 +301,3 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 elapsed = time.perf_counter() - started logger.info("сквозной сценарий РБ: {:.1f} с, строк свода {}", elapsed, len(rows_after)) assert elapsed <= TIME_BUDGET_SECONDS - assert all(d.status is not DocumentStatus.NEEDS_CHECK for d in documents) diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index 52c1d07..cd5533a 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -254,7 +254,7 @@ - [x] **Step 1: Тест** по сценарию (сначала падает на отсутствии хелпера или данных). - [x] **Step 2–4:** хелпер → `pytest -m "integration and fixtures" tests/test_consolidation_fixtures.py -q` → PASS; `make check-full` (без `e2e`, если compose с парсером не поднят — отметить в ledger). -- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (тест под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыть после исправления; закрыты исправлением R-14 — документ за Q1 равнозначен нарастающему и квартальному, xfail снят). +- [x] **Step 5: Commit** `test(consolidation): сквозной сценарий Среза 4 на фикстурах группы «Русский бисквит»` (первый коммит теста шёл под `xfail(strict)`: найден дефект привязки Q1, см. `task-13-report.md`; Step 2–4 закрыты после исправления R-14 — документ за Q1 равнозначен нарастающему и квартальному, xfail снят; `make check-full` целиком выполняется в Task 14). --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index adf0362..cb03ae7 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -264,5 +264,5 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 77 | 2026-10-06 | R-12: все текстовые ячейки экспорта (компания, ИНН, счёт, период, контрагент, ИНН контрагента, «Сопоставлено с», тип, способ, лист «Пробелы») проходят `_put_text`: запрещённые XML-символы (`ILLEGAL_CHARACTERS_RE`) вырезаются, тип ячейки принудительно `"s"`, поэтому `=`, `+`, `-`, `@` в начале не становятся формулой, а текст читается обратно без изменений | файлы внешние, аналитики открывают выгрузку в Excel: без этого `=HYPERLINK(...)` из чужого файла выполнился бы в их книге, а управляющий символ давал `IllegalCharacterError` → 500 | текст ячейки с ведущим `=` больше не вычисляется — это желаемо; вырезанный управляющий символ из выгрузки пропадает | | 78 | 2026-10-06 | R-13: параметр `entity_names` убран из `build_workbook` — сигнатура `build_workbook(c, rows) -> bytes`; сборка книги выполняется в `run_in_threadpool` (линейная по строкам, явный счётчик строк вместо `sheet.max_row`) | строки и пробелы уже несут имена ЮЛ, параметр не использовался (YAGNI); план предписывал параметр — Interfaces Task 12 исправлены | если фронту понадобится словарь переопределения имён — вернуть параметр | | 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | -| 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из spec consolidated-osv «Сопоставление из плана среза» верно только для нарастающих документов (одна запись = одна строка свода) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec вводит в заблуждение для квартальных документов; уточнить при архивации (не меняет код) | +| 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из прежней формулировки spec consolidated-osv «Сопоставление из плана среза» нарушается дважды: квартальные записи склеиваются в одну строку свода, а записи в парах-пробелах (счета 66/76 РБ) дают `propagated`, но строк свода не дают; верно оно только когда записи не склеены и не лежат в пробелах (нарастающие документы без пробелов) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec исправлена (fix round 1, M1); код не менялся | | 81 | 2026-10-06 | R-14 + R-14a: ОСВ за `Q1` (`period_quarter = Q1`) равнозначна и нарастающей, и квартальной. `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `select_sources` (для версии `Q2+` ОСВ за `Q1` — квартальный источник `Q1`; нарастающая за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающая «как есть», квартальная — склейка, №50), в адаптере чек-листа и — только для `document_type = OSV` — в `consolidation_documents._cumulative`, то есть в `fits` (ОСВ за `Q1` подходит любой версии года при любом флаге) и в ключе замены `_key` (нарастающая и квартальная ОСВ за `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`). R-14a: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4`: для них прежнее правило D-5 (NULL → нарастающий → только квартал версии), ключ замены не меняется | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающая и квартальная ОСВ за `Q1` одной пары вытесняют друг друга при привязке; если и ББ/ОФР за `Q1` должны подхватываться версиями `Q2`–`Q4` — снять условие `document_type = OSV` в `_cumulative` | diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md index 88e67de..2f3d73e 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -116,7 +116,9 @@ propagated_entry_ids, alias_created, alias_moved_from}`. Уже связанну - **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` + `counterparty_id` и `PROPAGATED`; `propagated` ≥ 1; `summary.unmapped_total` уменьшился на число СТРОК сводной ОСВ, + получивших контрагента (квартальные записи одного контрагента склеены в одну строку), а `1 + propagated` равно числу + записей `osv_entry` с этим именем во всех привязанных ОСВ, включая пары-пробелы и записи, склеенные в одну строку #### Scenario: Алиас занят - **WHEN** `normalized` имени строки уже есть алиасом у другого ЮЛ, `move_alias = false` -- 2.47.2 From 2c6908a6e2a4255f22bb0b4059f425847c1da0fe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 08:02:50 +0400 Subject: [PATCH 24/27] =?UTF-8?q?docs(slice-4):=20README=20=D0=B8=20CLAUDE?= =?UTF-8?q?.md=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README: раздел «Консолидация (Срез 4)» (эндпоинты, статусы, блокировка, привязка, чек-лист, свод, сопоставление, экспорт, порядок блокировок), коды ошибок Среза 4 в «Ошибки API», раскладка. CLAUDE.md: модули консолидации, правила (`transition`, порядок блокировок «файл → документ → advisory ГК → версии», FOR NO KEY UPDATE, Q1-эквивалентность). design.md: решения №82–85; tasks.md: 5.2 выполнена. Co-Authored-By: Claude Sonnet Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 30 ++- README.md | 185 +++++++++++++++++- .../plans/2026-10-06-slice-4-consolidation.md | 4 +- .../changes/slice-4-consolidation/design.md | 4 + .../changes/slice-4-consolidation/tasks.md | 6 +- 5 files changed, 215 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ed7552..4b617ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,10 @@ `domain/` (чистые функции по §§ правил, без БД и сессий: `policy.py` — видимость справочников §10.1, `directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки - ОСВ (§1.3, §1.5, §9.3); для вычислений консолидации — Срез 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, + ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ, + R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py` + (чек-лист комплекта, ячейки `CellStatus`); `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки + и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. - **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; `storage`/`nats` — из `request.app.state`. **Сессия** — только `session: SessionDep` (`src/api/dependencies/db.py` = `Depends(get_async_session, scope="function")`): одна @@ -76,9 +79,32 @@ (`CounterpartyIndex` — резолв контрагента без policy). **Фоновых циклов три** (запускаются в lifespan при `NATS_ENABLED`; цикл маппинга — ещё и при подключённом S3 (storage)): публикатор outbox, вахтёр сроков и цикл маппинга (`MAPPING_POLL_SECONDS`, `MAPPING_MAX_ATTEMPTS`); inbox результат не читает и документов не создаёт. **Порядок блокировок: задание → файл → документы** (inbox, вахтёр, публикатор, маппер, - `confirm`, `delete`) — иначе взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`, + `confirm`, `delete`; с Среза 4 далее — advisory-lock ГК → версии консолидации, см. «Консолидации») — иначе + взаимоблокировки; автозамена дополнительно берёт advisory-lock по ЮЛ. Аудит: `FILE_UPLOAD`, `FILE_REPLACE` (системное, `user_id` пуст), `FILE_DELETE`, `DOCUMENT_CONFIRM`; смена статусов обмена и маппинга не аудируется (OQ-55). `osv_entry` пишет только маппер, один раз на файл (очистка перед вставкой); `TOTAL` не хранится. +- **Консолидации (Срез 4)** — `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export, + mapping), сервисы: `consolidations` (создание, список, карточка, периметр `PUT …/members`), `consolidation_guard` + (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), + `consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной + `POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` / + `on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты, + экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»). + **Правила:** (1) статус версии меняет ТОЛЬКО `consolidation_status.transition`; версия к вызову уже под `FOR UPDATE` у + вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ → + advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с `consolidation_version FOR UPDATE`; + `lock_versions_for_entity` блокирует надмножество версий «в работе» (ЮЛ — член ИЛИ та же ГК), создание версии берёт + per-ГК `pg_advisory_xact_lock` (R-2); (3) `lock_document`/`lock_file_documents` — `FOR NO KEY UPDATE` (R-3: вставка + `consolidation_document` берёт `FOR KEY SHARE` на документ и взаимоблокировалась бы с `FOR UPDATE`); (4) сводная ОСВ, + чек-лист и признак контрагента — чистые функции, в БД не пишутся; единственное хранимое — ручное сопоставление + (`counterparty_id` + `counterparty_match_kind` + алиас); (5) ключ замены привязки — «тип, счёт, квартал, нарастающий» + (R-1); ОСВ за Q1 равнозначна нарастающей и квартальной (R-14, Q1-эквивалентность — только для ОСВ, ББ/ОФР за Q1 версиям Q2–Q4 + не подходят, R-14a); (6) «строка версии» для ручного сопоставления/автоприменения — только строки свода (R-11); (7) 404 — + код `NOT_FOUND`, различие по тексту; правки — `ensure_version_editable` (`VERSION_NOT_EDITABLE`/`LOCK_REQUIRED`/ + `LOCKED_BY_OTHER` без `locked_by_name`). Деньги в ответах — строки с двумя знаками. Аудит: `CONSOLIDATION_CREATE`, + `CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE`, + `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`, `EXPORT`; `FILE_REPLACE.unbound_version_ids`. Контракт для фронта — + `../docs/dev/api-contract-slice-4.md`; решения — `openspec/changes/slice-4-consolidation/design.md`. - **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции; тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`). В маршрутах — `RequestAudit` (`get_request_audit`: пользователь, IP, user-agent). diff --git a/README.md b/README.md index 2074e1f..f617d9e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и замена», «Ограничения Среза 2c», «Сквозной прогон (e2e)». +Срез 4 («консолидация»): схема по DBML v5.16, версии консолидации ГК за период, периметр, блокировка «взять в работу», +статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx. +Спецификация — `openspec/changes/slice-4-consolidation/`; раздел ниже: «Консолидация (Срез 4)». + ## Предварительные требования - **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит @@ -455,7 +459,7 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (файл без разбираемой ОСВ → файл `ERROR`). `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; -`period_check` — `UNKNOWN` до версии консолидации (Срез 4). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. +`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. ## Цикл маппинга @@ -539,11 +543,158 @@ S3 (storage); без S3 — предупреждение в логе, цикл - ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу. - ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга, поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца). -- `period_check` — `UNKNOWN` до Среза 4; удаление файла после консолидаций (FK на `document` из ББ/ОФР и версий) - в этом срезе не рассматривается; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). +- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы, а их файл удалить + нельзя — удаление файла с документом в версии невозможно (OQ-110, Срез 4); FK `financial_statement_meta` и `ex_snapshot_item` + — Срезы 3 и 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d). - Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства. +## Консолидация (Срез 4) + +Срез 4: версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы (автоматические по событиям +документов и ручные), привязка подтверждённых документов к версии, чек-лист комплекта, сводная ОСВ (чистая функция от +версии, в БД не хранится), ручное сопоставление контрагента и экспорт в xlsx. Спецификация — +`openspec/changes/slice-4-consolidation/` (`design.md`: D-1…D-16 и таблица «Decisions made without discussion»); +контракт для фронта — `../docs/dev/api-contract-slice-4.md`; правила — `../docs/context/05-domain-rules.md` §2, §3, §9, §10. + +### Эндпоинты + +Все маршруты — под `/api/v1`; права — `CONSOLIDATION_VIEW` (чтение) и `CONSOLIDATION_EDIT` (правки). Порядок проверок: +право → видимость ГК (чужое и несуществующее — один 404) → статус «в работе» и своя блокировка → операция. + +| Метод и путь | Право | Что делает | +|---|---|---| +| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); члены — текущие члены ГСК (≥ 2) | +| `GET /company-groups/{id}/consolidations` | VIEW | периоды ГК и их версии (`in_work_only`) | +| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр, чек-лист по ЮЛ, история статусов, блокировка | +| `POST /consolidations/versions/{id}/lock` | EDIT | «взять в работу»; `{takeover_reason}` — перехват чужой блокировки с причиной | +| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) | +| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | +| `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) | +| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки | +| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры (`limit` ≤ 2000, по умолчанию 500) | +| `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]` | +| `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | +| `GET /consolidations/versions/{id}/counterparty-candidates` | VIEW | кандидаты для сопоставления: `scope=group\|all`, `q`, `entry_id` | +| `POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty` | EDIT | ручное сопоставление контрагента строки свода | +| `POST /legal-entities/{id}/files?consolidation_version_id=` | `DOCUMENT_EDIT` | загрузка в контексте версии (необязательный параметр, Срез 2c) | + +Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается. + +### Статусы версии + +Автоматические переходы (`services/consolidation_status.transition` — единственное место смены `consolidation_version.status`; +пишет `consolidation_status_history` и `audit_event(CONSOLIDATION_STATUS_CHANGE)`): + +| Событие | Переход | `changed_by` | +|---|---|---| +| загрузка с `consolidation_version_id`, принят ≥ 1 файл | `INITIATED` → `DOCUMENTS_PROCESSING` | загрузивший | +| маппинг создал документ `NEEDS_CHECK`, подходящий версии | `DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | `uploaded_by` файла (нет — переход пропускается, лог) | +| подтверждение документа, привязанного к версии | `INITIATED`/`DOCUMENTS_PROCESSING` → `DATA_CORRECTION` | подтвердивший | +| `POST …/status {CANCELLED, comment}` | любой «в работе» → `CANCELLED` (снимает блокировку) | пользователь | +| `POST …/status {INITIATED}` | `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` → `INITIATED` (привязки сохраняются) | пользователь | + +«В работе» — `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`. Остальные переходы (маршрут согласования) — Срез 7; +вручную они дают `409 TRANSITION_NOT_ALLOWED`. Загрузка и подтверждение документа блокировкой не ограничиваются. + +### Блокировка + +`consolidation_lock`: одна активная блокировка на версию (частичный UNIQUE `released_at IS NULL`). `POST …/lock` без тела: +свободна — `201`, своя — `200` (идемпотентно), чужая — `409 LOCKED_BY_OTHER`. С `takeover_reason` (непустая) чужая +блокировка снимается, создаётся новая (`taken_over_from`), `audit_event(CONSOLIDATION_LOCK_TAKEOVER)`. Любая правка версии +(`PUT …/members`, `POST …/status`, ручное сопоставление) требует статус «в работе» и **свою** активную блокировку. +Блокировка не истекает по времени (OQ-119); `CANCELLED` её снимает. + +### Привязка документов + +Версия владеет явным комплектом документов (`consolidation_document`). Документ **подходит** версии, если ЮЛ — член +периметра, `period_year` = год версии и: нарастающий (ББ/ОФР всегда) — `period_quarter` = квартал версии, квартальный — +`period_quarter` ≤ квартал версии. **Исключение для ОСВ (R-14):** документ ОСВ за `Q1` равнозначен нарастающему и +квартальному и подходит версии любого квартала года (парсер помечает ОСВ за Q1 как нарастающую); ББ/ОФР за Q1 версиям +Q2–Q4 не подходят (R-14a). Привязываются только `CONFIRMED` документы незаменённых файлов. + +- **Привязка:** подтверждение документа (ко всем версиям «в работе» ГК ЮЛ), создание версии и добавление ЮЛ в периметр + (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним `confirmed_at`). +- **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`) + и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются. +- **Файл с документом в версии удалить нельзя** (привязываются только `CONFIRMED`, а их файл удалять запрещено — OQ-110). +- `period_check` документа остаётся `UNKNOWN` — подходящесть есть функция (документ, версия), а не свойство документа (OQ-120). + +### Чек-лист + +`GET …/checklist` и `members[].checklist` карточки: строки — члены периметра, колонки — `required_document_rule` подхода +версии (`BASIC`: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90). Статус ячейки: `CONFIRMED` (пара покрыта привязанными +документами), `NEEDS_CHECK`/`RECOGNIZED` (есть неподтверждённый подходящий), `PROCESSING` (у ЮЛ есть файл в обработке), +`ERROR` (последний файл ЮЛ в `ERROR`), `MISSING`; `missing_quarters` — пропущенные кварталы при частичном покрытии ОСВ. +`PROCESSING` ставится по всем недостающим столбцам ЮЛ, пока файл не смаплен (до маппинга счёт файла неизвестен). + +### Сводная ОСВ + +Чистая функция от версии (`domain/consolidated_osv.build` + `domain/consolidation_periods.select_sources`), в БД не пишется +(DECISION-5); одна и та же для страницы, сводки и экспорта (`services/consolidated_osv.py:_build`, весь свод версии в памяти — +порог для кэша OQ-121). Источники — строки привязанных ОСВ-документов (`group_type = COUNTERPARTY`, `data_type BU/SUM`). + +- На пару (ЮЛ, счёт): нарастающий документ за квартал версии — как есть; иначе квартальные документы `Q1..Qn` + склеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn, + отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — **пробел** + (`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». +- Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → имя → алиас; в БД не пишется, + `match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`. + Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`. +- Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q` + (имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки. +- `warnings` строк — диагностика (расхождение `counterparty_id` между кварталами), не контракт. + +### Сопоставление контрагента + +`GET …/counterparty-candidates` — до 100 ЮЛ: `scope=group` (периметр и ГК, `q` необязателен) или `scope=all` (весь справочник, +`q` ≥ 3 символов, иначе `422 QUERY_TOO_SHORT`); `recommended` — нормализованное имя/алиас совпало с именем строки `entry_id`. +`POST …/osv/entries/{entry_id}/counterparty {legal_entity_id, move_alias}` — одна транзакция: + +1. строка должна быть строкой **свода** версии (`COUNTERPARTY`, `BU`/`SUM`, документ привязан; `NU`/`ACCOUNT` — 404); +2. строка получает `counterparty_id` и `match_kind = MANUAL`; имя/ИНН из файла не меняются; +3. создаётся алиас имени строки у выбранного ЮЛ (`LEGAL_ENTITY_CHANGE`); алиас, закреплённый за другим ЮЛ, — + `409 ALIAS_TAKEN` (`owners[]`), с `move_alias = true` переносится (право `DIRECTORY_DELETE` не нужно); +4. автоприменение: строки всех ОСВ версии с тем же именем, пустым `counterparty_id` и ИНН пустым или равным — `PROPAGATED`; +5. `audit_event(OSV_COUNTERPARTY_MAP)` с `before/after` и `propagated_entry_ids`. + +Ответ — `{row, propagated, alias_created, alias_moved_from}`; `row` бывает `null` (строка привязанного документа, не попавшая +в свод). Строки `osv_entry` общие у версий одного документа: сопоставление в одной версии меняет строку для всех (OQ-123). + +### Экспорт + +`GET …/osv/export` — xlsx: лист «Сводная ОСВ» (фильтры как у `GET …/osv`, без пагинации; суммы — числовые ячейки `#,##0.00`) и +лист «Пробелы» (по своду целиком, от фильтров не зависит). Имя — заголовок `Content-Disposition` +(`filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-_v.xlsx`). Текстовые ячейки пишутся как текст +(без формул из внешних строк). Пишет `audit_event(EXPORT)`. + +### События аудита + +`CONSOLIDATION_CREATE`, `CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE` +(замена периметра; состав не менялся — события нет), `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE` (алиасы сопоставления), +`EXPORT`; `FILE_REPLACE` дополнен `unbound_version_ids`. + +### Порядок блокировок и инварианты + +- Блокировки строк: **файл → документ → advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с + `consolidation_version FOR UPDATE`. `lock_versions_for_entity` блокирует надмножество версий «в работе» (член ИЛИ та же + ГК), создание версии берёт `pg_advisory_xact_lock` по ГК (R-2) — иначе привязка тихо теряется. +- Документы под `FOR NO KEY UPDATE` (R-3): вставка `consolidation_document` берёт `FOR KEY SHARE` на документ и конфликтует + с `FOR UPDATE`. Сопоставление: версия → advisory по алиасу → алиасы → строки `osv_entry` с тем же именем. +- Статус версии меняет только `consolidation_status.transition`, версия под `FOR UPDATE` у вызывающего. + +### Ограничения Среза 4 + +- Маршрут согласования (`CONSOLIDATION_RF` и далее), «Добавить версию», `CONFIRM`, `EXTENDED`, гудвилл — Срезы 6–7; + шахматка и ВГО — Срез 5; ББ/ОФР в своде не участвуют; UI — отдельный исполнитель. +- Открытые вопросы — `../docs/context/07-open-questions.md`: OQ-118 (запрет загрузки/подтверждения после `DATA_CORRECTION`), + OQ-119 (истечение блокировки), OQ-120 (`period_check`), OQ-121 (порог размера свода), OQ-122 (ячейка `PROCESSING`), + OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного + сопоставления), OQ-127 (рекомендация кандидата), OQ-128 (неоднозначность дорезолва), OQ-129 (формулировка «1 + propagated»). +- Экспорт: числа xlsx — IEEE-754, суммы свыше ~10^13 могут потерять младшую копейку. +- Тест на реальных данных — `api/tests/test_consolidation_fixtures.py` (маркер `fixtures`): ОСВ РБ и Счастливочной за Q1–Q3 2024 + → версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам. + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -571,7 +722,7 @@ email без учёта регистра проверяет только сер | Код | HTTP | Где | |---|---|---| -| `NOT_FOUND` | 404 | объекта нет или он вне видимости | +| `NOT_FOUND` | 404 | объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено» | | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр | @@ -602,6 +753,18 @@ email без учёта регистра проверяет только сер | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | | `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | +| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | +| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | +| `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) | +| `ENTITY_NOT_IN_GROUP` | 422 | `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` | +| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии | +| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки | +| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | +| `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки, загрузка с версией) либо `CANCELLED` при «взять в работу» | +| `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки | +| `LOCKED_BY_OTHER` | 409 | версию держит другой пользователь: из `POST …/lock` — `locked_by`, `locked_by_name`, `locked_at`; из правок (`ensure_version_editable`) — только `locked_by`, `locked_at`; гонка двух взятий (частичный UNIQUE) — то же 409 | +| `NOT_LOCK_OWNER` | 409 | `DELETE …/lock`: блокировка чужая (`locked_by`, `locked_at`) | +| `TRANSITION_NOT_ALLOWED` | 409 | ручной переход вне таблицы Среза 4, в т. ч. повтор того же статуса (`from_status`, `to_status`) | Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`; при загрузке первичных файлов лимит `UPLOAD_MAX_BYTES` отдаётся не 413, а строкой `rejected[]` с кодом `FILE_TOO_LARGE`. Недостаточно прав — 403 строкой (`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. Под `/api/v1` SPA не отвечает: неверный @@ -694,7 +857,8 @@ email без учёта регистра проверяет только сер MinIO `9000`), поэтому после `make up` тесты работают «из коробки». То же относится к `make migration-cycle` и `make check-full`: они тоже читают `.env.test` через `pytest.ini`. - **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером - `fixtures` без каталога пропускаются с понятной причиной. + `fixtures` без каталога пропускаются с понятной причиной. Срез 4: `tests/test_consolidation_fixtures.py` — сквозной сценарий + на ОСВ РБ и Счастливочной (golden-снимки, без tabular-parser). ### Сквозной прогон (e2e) @@ -755,7 +919,7 @@ api/ scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) src/ app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA - api/endpoints/ эндпоинты (me.py, dev_auth.py, directories/, admin/); router.py собирает /api/v1 + api/endpoints/ эндпоинты (me.py, dev_auth.py, directories/, admin/, files/, consolidations/); router.py собирает /api/v1 api/dependencies/ зависимости FastAPI (current_user, проверка прав) core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), logger, exception_handlers @@ -764,10 +928,15 @@ api/ repositories/ доступ к данным schemas/ pydantic-схемы запросов и ответов services/ бизнес-логика; primary_files (загрузка/удаление), result_mapping (цикл маппинга и автозамена), - file_status (статус файла по заданию), documents (карточки, подтверждение), recognition_exchange (обмен) + file_status (статус файла по заданию), documents (карточки, подтверждение), recognition_exchange (обмен); + Срез 4 — consolidations (версии, периметр), consolidation_guard (общий пролог правок), consolidation_locks, + consolidation_status (`transition`), consolidation_documents (привязка), consolidated_osv (свод, кандидаты), + counterparty_mapping (ручное сопоставление), osv_export (xlsx) common/ доменные ошибки (`{detail: {code, message}}`), часы domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn, - file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3) + file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3); + Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy + (`ensure_version_editable`, §10) tests/ pytest (юнит и integration/fixtures по маркерам) web/ фронтенд (React 18 + TypeScript, rsbuild, antd): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка web/dist -> api/web/ setup/ бутстрап шаблона через Nexus банка (pip.conf, setup.sh) diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index cd5533a..e34061a 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -264,8 +264,8 @@ - Modify: `README.md` (раздел «Срез 4»: эндпоинты, статусы, блокировка, привязка, свод, сопоставление, экспорт, коды ошибок), `CLAUDE.md` (новые модули, порядок блокировок «файл → документ → версия», правило «переходы только через `transition`»), `openspec/changes/slice-4-consolidation/tasks.md` (все `[x]`) - Modify (worktree docs): `context/05-domain-rules.md` (§2, §3, §9.1, §9.3, §9.5, §10 по proposal docs-change), `context/03-architecture.md`, `context/07-open-questions.md` (закрыть OQ-03, OQ-19, OQ-40, OQ-110 (Срез 4); добавить OQ-118…OQ-123 с текстами из design D-6/D-7/D-12/D-1/D-13/Risks), `context/09-delivery-plan.md` (Срез 4 по факту), `context/10-user-journey.md` (эндпоинты у шагов 2.1–2.6, 3.1), `dev/qa-guide.md` §7, новый `dev/api-contract-slice-4.md` (маршрут → схема → коды ошибок → экран макета; выжимка из OpenAPI `GET /openapi.json`), `openspec/changes/slice-4-consolidation/tasks.md` 2–4 → `[x]` -- [ ] **Step 1:** Проверить `GET /openapi.json` на тестовом приложении (`test_openapi_routes`) — все маршруты консолидации с `operation_id` и `responses`. -- [ ] **Step 2:** Написать документы; `make check` (линтер markdown, если есть); коммит в arm-core `docs(slice-4): README и CLAUDE.md по консолидации`; коммит в docs `docs(slice-4): правила §2/§3/§9/§10 по факту, OQ, контракт API для фронта, QA-гайд`. +- [x] **Step 1:** Проверить `GET /openapi.json` на тестовом приложении (`test_openapi_routes`) — все маршруты консолидации с `operation_id` и `responses`. +- [x] **Step 2:** Написать документы; `make check` (линтер markdown, если есть); коммит в arm-core `docs(slice-4): README и CLAUDE.md по консолидации`; коммит в docs `docs(slice-4): правила §2/§3/§9/§10 по факту, OQ, контракт API для фронта, QA-гайд`. --- diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index cb03ae7..d3c0dbb 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -266,3 +266,7 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 79 | 2026-10-06 | Сквозной тест на фикстурах (Task 13): ГК «Русский бисквит» сидится в Ростовском филиале, а не в «СПб РФ», как в плане | dev-пользователь `ka_rf` (seed) — сотрудник Ростовского филиала; видимость ГК и документов у РФ ограничена своим филиалом (policy), у `ka_rf` нет СПб; смысл сценария (КА РФ ведёт свою ГК) не меняется | тест не проверяет конкретное название филиала; при появлении dev-пользователя СПб РФ — заменить константу `ROSTOV` | | 80 | 2026-10-06 | Сверка сопоставления на реальных данных: `unmapped_total` падает на число СКЛЕЕННЫХ строк свода, получивших контрагента, а `1 + propagated` равно числу записей `osv_entry` с этим именем в ОСВ РБ, привязанных к версии (включая пары-пробелы 66/76, строк в своде не дающие); равенство «`unmapped_total` падает на `1 + propagated`» из прежней формулировки spec consolidated-osv «Сопоставление из плана среза» нарушается дважды: квартальные записи склеиваются в одну строку свода, а записи в парах-пробелах (счета 66/76 РБ) дают `propagated`, но строк свода не дают; верно оно только когда записи не склеены и не лежат в пробелах (нарастающие документы без пробелов) | на фикстурах квартальные записи одного контрагента склеиваются (D-2): в РБ «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» — 18 записей, 4 строки свода по счетам 60/62; поведение продукта (propagated считает записи хранилища, summary — строки свода) согласовано с D-2/D-3, неточна формулировка spec | формулировка сценария в spec исправлена (fix round 1, M1); код не менялся | | 81 | 2026-10-06 | R-14 + R-14a: ОСВ за `Q1` (`period_quarter = Q1`) равнозначна и нарастающей, и квартальной. `consolidation_periods.effective_cumulative(is_cumulative, period_quarter)` (ложна для `Q1`, иначе `counts_as_cumulative`) используется в `select_sources` (для версии `Q2+` ОСВ за `Q1` — квартальный источник `Q1`; нарастающая за квартал версии по-прежнему побеждает; для версии `Q1` — прежнее поведение: нарастающая «как есть», квартальная — склейка, №50), в адаптере чек-листа и — только для `document_type = OSV` — в `consolidation_documents._cumulative`, то есть в `fits` (ОСВ за `Q1` подходит любой версии года при любом флаге) и в ключе замены `_key` (нарастающая и квартальная ОСВ за `Q1` одной пары — один слот, побеждает поздний `confirmed_at`, при равенстве — больший `id`). R-14a: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4`: для них прежнее правило D-5 (NULL → нарастающий → только квартал версии), ключ замены не меняется | парсер помечает ОСВ за первый квартал нарастающей (период с начала года = Q1); без этого сквозной тест на фикстурах РБ давал 17 привязок из 22, пары 60/62(/76) уходили в пробел `[Q1]`, свод пуст | нарастающая и квартальная ОСВ за `Q1` одной пары вытесняют друг друга при привязке; если и ББ/ОФР за `Q1` должны подхватываться версиями `Q2`–`Q4` — снять условие `document_type = OSV` в `_cumulative` | +| 82 | 2026-10-06 | Task 14 (документы): открытые вопросы владельцу заведены в `07-open-questions.md` как OQ-124…OQ-129 (после OQ-118…123 из docs-proposal): Q1-эквивалентность ОСВ (R-14/R-14a), склейка Q1 версии Q1 (№50), область ручного сопоставления (R-11), нечёткая рекомендация кандидата (O-3), неоднозначность дорезолва при тёзке (T11.9), формулировка сценария «1 + propagated» (R-15) | список вопросов ждёт подтверждения владельца; допущения уже реализованы в коде, поэтому вопросы — «допущение принято», а не «открыт» | если владелец решит иначе, меняется код и docs в соответствующем месте (указано в каждом OQ) | +| 83 | 2026-10-06 | OQ-03 и OQ-19 в реестре помечены закрытыми решением владельца 06.10.2026, OQ-40 — «допущение принято и реализовано» (не «закрыт»: правило банка не подтверждено аналитикой), OQ-110 — закрыт только для Среза 4 (остаток FK — Срезы 3 и 7) | docs-proposal велит закрыть OQ-03/19/110/40; формулировка по OQ-40 следует правилу CLAUDE.md «агент не закрывает вопросы сам» (допущение, а не решение) | реестр вопросов формально неточен, если владелец считает OQ-40 решённым | +| 84 | 2026-10-06 | `dev/qa-guide.md`: новый раздел 7 «Консолидация через API (Срез 4)», прежние разделы 7–10 сдвинуты на 8–11, внутренние ссылки «раздел 8» → «раздел 9» | docs-proposal называет раздел §7; внешних ссылок на номера разделов гайда в репозиториях нет (ссылаются по названию) | сторонние закладки на «раздел 8» (Сброс стенда) указывают на «Известные ограничения» | +| 85 | 2026-10-06 | Контракт API (`../docs/dev/api-contract-slice-4.md`) описывает доменные 422-коды (`COMMENT_REQUIRED`, `PERIMETER_TOO_SMALL` и др.) вручную: в OpenAPI они не объявлены (там общий 422 `HTTPValidationError`; объявлены 404/409/ErrorDetail) | менять объявления маршрутов в Task 14 нельзя (документная задача); OpenAPI проверен `test_openapi_routes` — у всех маршрутов консолидации есть `operationId` и `responses` | фронт не получит доменные 422-коды из сгенерированного клиента, пока responses не расширены (follow-up) | diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 7559995..8464445 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -62,5 +62,7 @@ экспорт открывается. Найденный тестом дефект (нарастающий Q1 не привязывался к версии Q3 и не считался квартальным Q1) исправлен рулингом R-14 (design #81): документ за Q1 равнозначен нарастающему и квартальному; xfail снят. -- [ ] 5.2 **Документы.** README (консолидации, статусы Среза 4, свод, сопоставление, экспорт, коды ошибок), CLAUDE.md - (новые модули, порядок блокировок), `openspec` спеки; синхронизация с docs-change (OQ, §2/§3/§9). +- [x] 5.2 **Документы.** README (раздел «Консолидация (Срез 4)»: эндпоинты, статусы, блокировка, привязка, чек-лист, свод, + сопоставление, экспорт, порядок блокировок; коды ошибок в «Ошибки API»), CLAUDE.md (новые модули, правила: `transition`, + порядок блокировок «файл → документ → advisory ГК → версии», `FOR NO KEY UPDATE`, Q1-эквивалентность), синхронизация с + docs-change (§2/§3/§9/§10, OQ-03/19/40/110 закрыты, OQ-118…129, контракт API, QA-гайд). `openspec/specs/` и архивация — человеку. -- 2.47.2 From 98922cc75cda05b301ff15f00782c54e026b7b27 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 08:10:57 +0400 Subject: [PATCH 25/27] =?UTF-8?q?docs(slice-4):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20README=20=D0=B8=20design.md=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=2014?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 2 +- openspec/changes/slice-4-consolidation/design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f617d9e..85c697d 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` - **Отвязка:** автозамена файла (привязки всех документов заменённого файла; версии — в `FILE_REPLACE.after_value.unbound_version_ids`) и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются. - **Файл с документом в версии удалить нельзя** (привязываются только `CONFIRMED`, а их файл удалять запрещено — OQ-110). -- `period_check` документа остаётся `UNKNOWN` — подходящесть есть функция (документ, версия), а не свойство документа (OQ-120). +- `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120). ### Чек-лист diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index d3c0dbb..71eb5f3 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -269,4 +269,4 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 82 | 2026-10-06 | Task 14 (документы): открытые вопросы владельцу заведены в `07-open-questions.md` как OQ-124…OQ-129 (после OQ-118…123 из docs-proposal): Q1-эквивалентность ОСВ (R-14/R-14a), склейка Q1 версии Q1 (№50), область ручного сопоставления (R-11), нечёткая рекомендация кандидата (O-3), неоднозначность дорезолва при тёзке (T11.9), формулировка сценария «1 + propagated» (R-15) | список вопросов ждёт подтверждения владельца; допущения уже реализованы в коде, поэтому вопросы — «допущение принято», а не «открыт» | если владелец решит иначе, меняется код и docs в соответствующем месте (указано в каждом OQ) | | 83 | 2026-10-06 | OQ-03 и OQ-19 в реестре помечены закрытыми решением владельца 06.10.2026, OQ-40 — «допущение принято и реализовано» (не «закрыт»: правило банка не подтверждено аналитикой), OQ-110 — закрыт только для Среза 4 (остаток FK — Срезы 3 и 7) | docs-proposal велит закрыть OQ-03/19/110/40; формулировка по OQ-40 следует правилу CLAUDE.md «агент не закрывает вопросы сам» (допущение, а не решение) | реестр вопросов формально неточен, если владелец считает OQ-40 решённым | | 84 | 2026-10-06 | `dev/qa-guide.md`: новый раздел 7 «Консолидация через API (Срез 4)», прежние разделы 7–10 сдвинуты на 8–11, внутренние ссылки «раздел 8» → «раздел 9» | docs-proposal называет раздел §7; внешних ссылок на номера разделов гайда в репозиториях нет (ссылаются по названию) | сторонние закладки на «раздел 8» (Сброс стенда) указывают на «Известные ограничения» | -| 85 | 2026-10-06 | Контракт API (`../docs/dev/api-contract-slice-4.md`) описывает доменные 422-коды (`COMMENT_REQUIRED`, `PERIMETER_TOO_SMALL` и др.) вручную: в OpenAPI они не объявлены (там общий 422 `HTTPValidationError`; объявлены 404/409/ErrorDetail) | менять объявления маршрутов в Task 14 нельзя (документная задача); OpenAPI проверен `test_openapi_routes` — у всех маршрутов консолидации есть `operationId` и `responses` | фронт не получит доменные 422-коды из сгенерированного клиента, пока responses не расширены (follow-up) | +| 85 | 2026-10-06 | Контракт API (`../docs/dev/api-contract-slice-4.md`) описывает доменные 422-коды (`COMMENT_REQUIRED`, `PERIMETER_TOO_SMALL` и др.) вручную: в OpenAPI они перечислены только свободным текстом в `description` ответа 422 (например, `CREATE_ERRORS` в `periods.py`: «RELATED_GROUP_MISMATCH, PERIMETER_TOO_SMALL или ошибка валидации тела»); машиночитаемой схемы и кодов нет (общий 422 `HTTPValidationError`; у 404/409 — `ErrorDetail`) | менять объявления маршрутов в Task 14 нельзя (документная задача); OpenAPI проверен `test_openapi_routes` — у всех маршрутов консолидации есть `operationId` и `responses` | фронт не получит доменные 422-коды из сгенерированного клиента, пока responses не расширены (follow-up) | -- 2.47.2 From 42c253146c22d8e4e9f7d996eea09540b0d9517c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 08:28:24 +0400 Subject: [PATCH 26/27] =?UTF-8?q?test(consolidation):=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B5=D1=81=D1=82=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=B0=D0=BD=D0=B4=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D0=BA-=D0=BB?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B0,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20spec=20=D0=B8=20design=20=D0=BF=D0=BE=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 (T8.2): тесты, что неподтверждённый документ даёт NEEDS_CHECK/RECOGNIZED только если подходит версии (fits): нарастающий Q2 и квартальный Q4 при версии Q3, другой год и ЮЛ вне периметра не влияют; положительный контроль. M-2: 404 `NOT_FOUND` с сообщением `…_NOT_FOUND`, ключ привязки с признаком нарастающего (R-1, R-14), порядок и режимы блокировок подтверждения (R-3) в spec. M-3: в design #39 и #43 — окно коммита перехода после загрузки. M-5: docstring lock_versions_for_entity про ЮЛ без ГК в периметре. Co-Authored-By: Claude Sonnet Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/consolidations.py | 6 +- api/tests/test_api_consolidation_checklist.py | 90 +++++++++++++++++++ .../changes/slice-4-consolidation/design.md | 4 +- .../specs/consolidated-osv/spec.md | 2 +- .../specs/consolidations-api/spec.md | 5 +- .../specs/directory-policy/spec.md | 3 +- .../specs/documents-api/spec.md | 10 ++- .../specs/primary-files/spec.md | 2 +- 8 files changed, 109 insertions(+), 13 deletions(-) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 92dc0cf..fb2a4f3 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -128,8 +128,10 @@ async def lock_versions_for_entity( ) -> list[ConsolidationVersion]: """Версии со статусом из `statuses`, где ЮЛ — член периметра; блокируются `FOR UPDATE ORDER BY id` (R-2). - Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; ЮЛ без ГК — без неё: членом версии оно - быть не может): иначе версия, создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там. + Первой берётся advisory-блокировка привязок ГК ЮЛ (`lock_group_bindings`; у ЮЛ без ГК её нет): иначе версия, + создаваемая параллельно, не видна ни здесь, ни подтверждённый нами документ — там. ЮЛ без ГК (после выхода из + группы или переноса) может оставаться членом версий «в работе» — его ловит ветка `is_member`; такое ЮЛ в периметре + заставляет любой `PUT …/members` отвечать `ENTITY_NOT_IN_GROUP`, пока его не уберут из периметра. Блокируется НАДМНОЖЕСТВО: версии этих статусов, где ЮЛ — член ИЛИ `consolidation.company_group_id` равен ГК ЮЛ (`legal_entity.company_group_id`). Иначе подтверждение документа ∥ `PUT …/members` теряет привязку: подтверждение diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index 298b56e..d37a093 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -151,3 +151,93 @@ async def test_checklist_visible_to_go_curator_hidden_from_foreign_office( assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND assert hidden.json() == unknown.json() assert (await admin.get(url)).status_code == HTTP_FORBIDDEN + + +async def _unconfirmed( # noqa: PLR0913 - тестовая фабрика: ЮЛ, счёт, период, признак и статус документа + session: AsyncSession, + entity: LegalEntity, + *, + account: str, + year: int = YEAR, + quarter: PeriodQuarter = Q3, + cumulative: bool = False, + status: DocumentStatus = DocumentStatus.NEEDS_CHECK, +) -> uuid.UUID: + file, job = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document( + session, + file=file, + job=job, + account=account, + year=year, + quarter=quarter, + status=status, + is_cumulative=cumulative, + ) + return document.id + + +def _column(account: str) -> int: + return [account for _, account in EXPECTED_RULES].index(account) + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("account", "year", "quarter", "cumulative", "status", "expected"), + [ + # положительный контроль: квартальный Q3 ОСВ версии Q3 подходит -> кандидат виден + ("76", YEAR, Q3, False, DocumentStatus.NEEDS_CHECK, "NEEDS_CHECK"), + # квартальный Q1 не позже квартала версии (Q3) подходит; RECOGNIZED + ("90", YEAR, Q1, False, DocumentStatus.RECOGNIZED, "RECOGNIZED"), + # нарастающий за Q2 при версии Q3 не подходит (нужен нарастающий за квартал версии) + ("62", YEAR, Q2, True, DocumentStatus.NEEDS_CHECK, "MISSING"), + ("62", YEAR, Q2, True, DocumentStatus.RECOGNIZED, "MISSING"), + # квартальный позже квартала версии не подходит + ("66", YEAR, PeriodQuarter.Q4, False, DocumentStatus.NEEDS_CHECK, "MISSING"), + # другой год не подходит + ("67", YEAR - 1, Q3, False, DocumentStatus.NEEDS_CHECK, "MISSING"), + ], + ids=["fits-quarterly-q3", "fits-quarterly-q1", "cumulative-q2", "cumulative-q2-recognized", "quarterly-q4", "year"], +) +async def test_checklist_unconfirmed_candidate_must_fit_version( # noqa: PLR0913, PLR0917 - параметры сценария + auth_client: AuthClientFactory, + seeded: AsyncSession, + account: str, + year: int, + quarter: PeriodQuarter, + cumulative: bool, + status: DocumentStatus, + expected: str, +) -> None: + """Неподтверждённый документ даёт `NEEDS_CHECK`/`RECOGNIZED` только если подходит версии (`fits`; I-1, T8.2).""" + version, (first, _) = await _world(seeded) + await _unconfirmed(seeded, first, account=account, year=year, quarter=quarter, cumulative=cumulative, status=status) + client = await auth_client(KA_RF) + + body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + + cells = _cells(body, "ООО А") + assert cells[_column(account)]["status"] == expected + others = [cell["status"] for position, cell in enumerate(cells) if position != _column(account)] + assert set(others) == {"MISSING"} # прочие столбцы не затронуты + assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"} + + +@pytest.mark.integration +async def test_checklist_unconfirmed_of_entity_outside_perimeter_has_no_effect( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Неподтверждённый документ ЮЛ вне периметра (той же ГК) не влияет на ячейки и не добавляет строк (I-1, T8.2).""" + version, _ = await _world(seeded) + group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.name == "ГК Русский бисквит"))).scalar_one() + outsider = await make_legal_entity(seeded, name="ООО В вне периметра", group=group) + await _unconfirmed(seeded, outsider, account="62") + await _unconfirmed(seeded, outsider, account="60", status=DocumentStatus.RECOGNIZED) + client = await auth_client(KA_RF) + + body = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + + assert [row["legal_entity_name"] for row in body["rows"]] == ["ООО А", "ООО Б"] + assert all({cell["status"] for cell in row["cells"]} == {"MISSING"} for row in body["rows"]) diff --git a/openspec/changes/slice-4-consolidation/design.md b/openspec/changes/slice-4-consolidation/design.md index 71eb5f3..ebff293 100644 --- a/openspec/changes/slice-4-consolidation/design.md +++ b/openspec/changes/slice-4-consolidation/design.md @@ -223,11 +223,11 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/ | 36 | 2026-10-06 | Переход версии при подтверждении пишется `transition(...)` с `user_id` подтвердившего, но без IP/user-agent (сигнатура хука `on_document_confirmed(session, audit: AuditService, *, document, user_id, now)` фиксирована планом); `transition` импортируется внутри хука (цикл `consolidation_status → consolidations → consolidation_documents`) | audit-trail требует для автоматического перехода только `user_id` инициатора | в `CONSOLIDATION_STATUS_CHANGE` автоматического перехода нет IP/UA — они есть в соседнем `DOCUMENT_CONFIRM` той же транзакции | | 37 | 2026-10-06 | Отдельная транзакция перехода после загрузки (`after_upload_to_version`) берётся из `app.state.session_audit.transaction` (та же фабрика `session_transaction`, что у аудита входа; без неё — `src.db.db.session_transaction`); `get_primary_files_service` передаёт её в `PrimaryFilesService(…, transaction)` | отдельной зависимости «фабрика транзакций» в приложении нет, а фикстуры тестов уже подставляют фабрику в `SessionAudit`; не меняем `app.py` и `conftest` | если `SessionAudit` когда-либо уйдёт из `app.state`, сервис молча возьмёт боевой `session_transaction` (в тестах это была бы чужая БД) | | 38 | 2026-10-06 | Переход после загрузки пишет `CONSOLIDATION_STATUS_CHANGE` с IP и user-agent запроса (в отличие от хука подтверждения, №36); `transition` вызывается из сервиса загрузки с `RequestAudit`-контекстом, но в своей транзакции и своим `AuditService(session)` | сервис знает IP/UA запроса, а `transition` их принимает; потери информации нет | нет | -| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически | +| 39 | 2026-10-06 | Версия в статусе `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` при загрузке не меняется, `after_upload_to_version` — no-op; если между проверкой и переходом версию аннулировали/перевели, переход не выполняется (статус перечитывается под `FOR UPDATE`), загрузка при этом остаётся принятой (201) | проверка до приёма и переход после — две разные транзакции, окно между ними неустранимо без блокировки версии, которую спека запрещает | версия, аннулированная в окне, остаётся аннулированной; файл принят и виден в ЮЛ, но к версии не привязан (привязка — только подтверждением). Окно покрывает и удаление ЮЛ из периметра между проверкой и переходом: версия всё равно перейдёт в `DOCUMENTS_PROCESSING` — допустимо. Отдельная транзакция перехода открывает второе подключение к БД, пока запрос держит своё: при множестве параллельных загрузок в контексте версии это давит на пул (по умолчанию 5+10) — пока теоретически Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в `DOCUMENTS_PROCESSING` без файлов (исправимо: вручную вернуть в `INITIATED`); возможное последующее исправление — переход после коммита запроса. | | 40 | 2026-10-06 | `on_documents_mapped` блокирует через `lock_versions_for_entity` надмножество по `IN_WORK_STATUSES` и фильтрует `DOCUMENTS_PROCESSING` ПОСЛЕ блокировки, а не передаёт `statuses={DOCUMENTS_PROCESSING}`; вызывается в `_map_candidate` ПОСЛЕ `map_file`, то есть после автозамены | при фильтре статуса в блокирующем запросе версия, которую параллельно переводят `INITIATED → DOCUMENTS_PROCESSING`, не ждалась бы; порядок «файл → документы → advisory ГК → версии» сохраняется, автозамена внутри `map_file` берёт advisory ЮЛ → старые файлы → advisory ГК → версии раньше хука; вызов до автозамены держал бы версии и затем ждал старые файлы (встречная блокировка с подтверждением) | блокируется чуть больше версий ГК (все «в работе»), на время короткой транзакции маппинга | | 41 | 2026-10-06 | Хук получает документы файла отдельным `SELECT` по `stored_file_id` после `map_file` (возвращаемое значение `map_file` — предупреждения — не менялось) | сигнатура `map_file` и её тесты не трогаются; документы только что записаны в этой же транзакции | один лишний запрос на файл | | 42 | 2026-10-06 | Переход по маппингу — только если среди документов файла есть `NEEDS_CHECK`, подходящий версии по `fits` (`fits` — D-5: период, год, член); статус файла/документа в других статусах (`RECOGNIZED`) переход не вызывает, привязки не создаются | спека osv-result-mapping | версия с неподходящим по периоду `NEEDS_CHECK` документом остаётся в `DOCUMENTS_PROCESSING` до подтверждения подходящего | -| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе | +| 43 | 2026-10-06 | R-10: ошибка (`Exception`) отдельной транзакции перехода после загрузки (`after_upload_to_version`) логируется (`logger.exception`, `error_category=version_transition_error`) и не меняет ответ: 201 с принятыми файлами; `BaseException` (`asyncio.CancelledError`) не перехватывается | приём файлов важнее побочного статуса; иначе 500 откатил бы транзакцию запроса, а принятые объекты S3 остались бы сиротами (как в `SessionAudit`, ошибка записи побочного события ответ не меняет) | версия может остаться `INITIATED` после загрузки до следующей загрузки или подтверждения документа; сбой виден только в логе Переход после загрузки фиксируется отдельной транзакцией ДО коммита транзакции запроса с файлами: если коммит запроса затем упадёт, версия может остаться в `DOCUMENTS_PROCESSING` без файлов (исправимо: вручную вернуть в `INITIATED`); возможное последующее исправление — переход после коммита запроса. | | 44 | 2026-10-06 | Статус ячейки — одно определение: `domain.checklist.CellStatus` (StrEnum); `ChecklistCellOut.status` в схеме API использует его, `Literal` `ChecklistCellStatus` удалён (T2.8) | два расходящихся определения — источник тихого рассинхрона домена и контракта | нулевая: OpenAPI-enum тот же набор значений | | 45 | 2026-10-06 | Проверка «подходящести» неподтверждённых документов (`fits`: член периметра, год, квартал) выполняется в сервисе (`_checklist_cells`), в домен `build` приходят уже подходящие; домен сам сверяет только ЮЛ, тип, счёт и статус (`RECOGNIZED`/`NEEDS_CHECK`) | `fits` живёт в `services.consolidation_documents` (домен не импортирует сервисы и ORM); дублировать предикат в домене — риск расхождения с привязкой | если `fits` изменится — меняется и чек-лист, что желательно; вне `fits` домен кандидатов не отсеивает | | 46 | 2026-10-06 | Выбор источников ОСВ в чек-листе — локальная функция `_osv_coverage` (нарастающий за квартал версии, иначе квартальные Q1..Qn все; нарастающий приоритетнее; пробел → `missing_quarters` + документы имеющихся кварталов), помечена `# Task 9 заменит на consolidation_periods.select_sources`; при полном отсутствии документов `missing_quarters = []` (перечисляются только при частичном покрытии, как в спеке) | `select_sources` появится в Task 9; план прямо разрешает локальную проверку до него | до Task 9 два места с правилом выбора; Task 9 удаляет локальное | diff --git a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md index 2f3d73e..06d8133 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidated-osv/spec.md @@ -100,7 +100,7 @@ MUST NOT агрегироваться между контрагентами. `co ### 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`); +привязанному к версии (иначе 404 `NOT_FOUND` с сообщением `ENTRY_NOT_FOUND`; код фиксированный, идентификатор — текст сообщения), и что ЮЛ существует (без policy; иначе 404 `NOT_FOUND` с сообщением `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)` на каждый); у выбранного ЮЛ алиаса нет → создаётся diff --git a/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md b/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md index 52c0fe3..ae2a4b4 100644 --- a/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/consolidations-api/spec.md @@ -43,7 +43,8 @@ Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATE версии и консолидации, `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` с одинаковым телом. +404 `NOT_FOUND` с сообщением `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND` и одинаковым телом (так же во всех +спецификациях среза: идентификаторы `…_NOT_FOUND` — текст сообщения при фиксированном коде `NOT_FOUND`). #### Scenario: Дерево версий - **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список @@ -51,7 +52,7 @@ Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATE #### Scenario: Чужой филиал - **WHEN** `ka_rf` другого филиала запрашивает карточку версии -- **THEN** 404 `VERSION_NOT_FOUND`, тело совпадает с ответом на случайный `uuid` +- **THEN** 404 `NOT_FOUND` (сообщение `VERSION_NOT_FOUND`), тело совпадает с ответом на случайный `uuid` ### Requirement: Периметр версии `PUT /consolidations/versions/{id}/members` (`CONSOLIDATION_EDIT`, policy, статус «в работе», своя активная блокировка) diff --git a/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md b/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md index 2cd6e41..fe6ea03 100644 --- a/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/directory-policy/spec.md @@ -4,7 +4,8 @@ ### Requirement: Видимость консолидаций и редактируемость версии Консолидация и её версии SHALL быть видимы тем же пользователям, что и её ГК (`company_group`); чужие и -несуществующие — 404 с одинаковым телом (`CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`). Правка версии (периметр, +несуществующие — 404 с одинаковым телом (код `NOT_FOUND`, сообщение `CONSOLIDATION_NOT_FOUND` / `VERSION_NOT_FOUND`; +так устроены все 404 этого среза: `NotFoundError` несёт только текст сообщения). Правка версии (периметр, ручная смена статуса, сопоставление контрагента) SHALL проходить проверки в порядке: право → видимость → статус версии ∈ {`INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`} (иначе 409 `VERSION_NOT_EDITABLE`) → активная `consolidation_lock` есть (иначе 409 `LOCK_REQUIRED`) и принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с diff --git a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md index a72f61d..9cea8a0 100644 --- a/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/documents-api/spec.md @@ -7,8 +7,9 @@ или `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`. +`FILE_SUPERSEDED`. Подтверждение берёт блокировки в порядке «файл → документ → (advisory-блокировка привязок ГК) → версии консолидации +(по возрастанию `id`)»: файл — `FOR UPDATE`, документ — `FOR NO KEY UPDATE` (R-3; совместима со вставкой привязки +`FOR KEY SHARE`), версии — `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` = квартал @@ -49,8 +50,9 @@ ### Requirement: Подхват документов при создании версии и расширении периметра При создании версии и при добавлении ЮЛ в периметр ядро SHALL привязать уже подтверждённые документы этого ЮЛ из -незаменённых файлов, подходящие версии (условия — как при подтверждении); на одну тройку (`document_type`, -`osv_account_code`, `period_quarter`) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL +незаменённых файлов, подходящие версии (условия — как при подтверждении); на один ключ (`document_type`, +`osv_account_code`, `period_quarter`, признак «нарастающий»; NULL признака — нарастающий; для ОСВ за Q1 признак не +различается, R-1, R-14) — документ с самым поздним `confirmed_at`. Удаление ЮЛ из периметра SHALL снимать привязку всех его документов в этой версии. Подхват и отвязка статус версии MUST NOT менять. #### Scenario: Фикстуры, подтверждённые до консолидации diff --git a/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md b/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md index 2be247a..4a6c3bf 100644 --- a/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md +++ b/openspec/changes/slice-4-consolidation/specs/primary-files/spec.md @@ -16,7 +16,7 @@ queued`, `pipeline` по типу: xls/xlsx/xlsb → `tabular`, pdf/png/jpg → `message`); код ответа 201, если принят хотя бы один, иначе 422. Тип документа и период при загрузке MUST NOT запрашиваться (макет `40`). Без доступного S3 — 503 `STORAGE_UNAVAILABLE`. Необязательный query-параметр `consolidation_version_id` SHALL проверяться до приёма файлов: версия видима (иначе 404 -`VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422 +`NOT_FOUND` с сообщением `VERSION_NOT_FOUND`), статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`), ЮЛ ∈ периметру (иначе 422 `ENTITY_NOT_IN_PERIMETER`); если принят хотя бы один файл и версия в `INITIATED`, она SHALL перейти в `DOCUMENTS_PROCESSING` (`changed_by` = загрузивший) в отдельной транзакции после приёма файлов. Блокировка версии для загрузки MUST NOT требоваться. -- 2.47.2 From 5fd691a5f796ce23c26dc7084b1a371dba860101 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 08:35:32 +0400 Subject: [PATCH 27/27] =?UTF-8?q?docs(slice-4):=20ledger=20SDD=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=83=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=204=20(=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8,=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BA=D0=B8,=20=D1=80=D1=83?= =?UTF-8?q?=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8,=20=D0=B8=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= =?UTF-8?q?=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- ...2026-10-06-slice-4-consolidation-ledger.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-slice-4-consolidation-ledger.md diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation-ledger.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation-ledger.md new file mode 100644 index 0000000..f79e95e --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation-ledger.md @@ -0,0 +1,174 @@ +# SDD ledger — plan: docs/superpowers/plans/2026-10-06-slice-4-consolidation.md + +Worktree arm-core: /Users/andrey.epifancev/projects/awp-ca/.wt/slice-4/arm-core (ветка feat/18-slice-4-consolidation, BASE ветки 714832e). Worktree docs: /Users/andrey.epifancev/projects/awp-ca/.wt/slice-4/docs (ветка feat/19-slice-4-consolidation). +Workspace: /Users/andrey.epifancev/projects/awp-ca/.wt/slice-4/arm-core/.superpowers/sdd/2026-10-06-slice-4-consolidation +Spec: openspec/changes/slice-4-consolidation/ (достижима, прочитана). SDD scripts: /Users/andrey.epifancev/.claude/plugins/cache/claude-plugins-official/superpowers/6.4.1/skills/subagent-driven-development/scripts +Pre-flight рулинги: preflight-rulings.md в workspace (R-1…R-6). + +## Pre-flight scan (план ↔ план ↔ спека) + +| Пара / задача | Производит → потребляет | Находка | +|---|---|---| +| T1 – T2 | `factories.py`: `make_osv_entry(kind)` / новые фабрики; `enums.py` | согласовано | +| T1 – T9/T10 | `Resolution` (T1, repositories) → `Resolver` Protocol в `domain` (T9) | домен не может импортировать репозиторий — **R-4** | +| T2 – T3/T5/T10/T11 | функции `repositories/consolidations.py` → сервисы | согласовано (сигнатуры в Interfaces T2) | +| T2 – T8 | `entity_files_state` создаётся в T2 и «модифицируется» в T8; build принимает и `files_state`, и `unbound` | дубль владения — **R-5** | +| T2 – T4/T6/T7 | `lock_versions_for_entity` | гонка потерянной привязки (confirm ∥ members, автозамена ∥ подхват, create ∥ confirm) — **R-2** | +| T3 – T5 – T6 | `consolidations.py`: заглушки `bind_confirmed`/`unbind_entity` (T3/T5) → реальные вызовы (T6) | согласовано (T6 явно «заглушки → реальные вызовы») | +| T3 – T8 | `_card`: `checklist = []` → `domain.checklist.build` | согласовано | +| T4 – T5/T11 | `ensure_version_editable` | согласовано | +| T4 – T6/T7 | `consolidation_status.transition` (автопереходы зовут её) | согласовано; версия должна быть под `FOR UPDATE` вызывающего (T6/T7 берут её через `lock_versions_for_entity`) | +| T5 – T6 | `unbind_entity` (T5: прямое удаление) → T6 | согласовано | +| T6 – T7 | оба правят `consolidation_documents.py`, `result_mapping.py`, `test_result_mapping.py`; `fits` → `on_documents_mapped` | согласовано, T7 запускается после T6 (последовательно) | +| T6 – спека | ключ замены «тройка» убивает сценарий «Нарастающий рядом с квартальными» | **R-1** | +| T6 – T2 (блокировки) | `lock_document`/`lock_file_documents` = FOR UPDATE; FK `consolidation_document → document` берёт KEY SHARE → взаимоблокировка confirm ∥ members | **R-3** | +| T8 – T9 | `checklist.py`: локальная проверка → `select_sources` | согласовано (явно в плане) | +| T9 – T10 | `build`/`Sources`/`Consolidated` → `_build` | согласовано | +| T10 – T11 – T12 | `services/consolidated_osv.py` (`_build` → `candidates` → `export`), `repositories/consolidations.py` | согласовано | +| T12 | `requirements-dev.txt` «Modify (ничего)» | шум — **R-6** | +| T13 – T1 | `test_mapping_golden` (kind) / хелпер `fixtures_helpers.map_golden_snapshot` | согласовано | +| T14 | docs в двух репозиториях | согласовано | +| Самосогласованность T1…T14 | тесты vs код vs файлы | T1: backfill SQL `'\s'` в python-строке миграции — экранировать; T3: `year=1999 → 422` ок; T4: перехват ∥ правка проверяется через `lock_version` на двух сессиях — ок; T9: `Gap(Q1,Q2,Q3)` для одного нарастающего Q2 при версии Q3 согласуется со спекой; прочее без противоречий | + +## Rulings +- Ruling: R-1 (design #11) ключ замены привязки включает признак нарастающего — спека противоречит сама себе (тройка vs сценарий «Нарастающий рядом с квартальными») — вытеснение нарастающего/квартального друг другом +- Ruling: R-2 (design #12) `lock_versions_for_entity` блокирует надмножество версий + создание под advisory ГК — иначе тихо теряется привязка — редкая потеря привязки +- Ruling: R-3 (design #13) `lock_document`/`lock_file_documents` → `FOR NO KEY UPDATE` — взаимоблокировка confirm ∥ members по KEY SHARE FK — нулевая +- Ruling: R-4 (design #14) `Resolution` определён в `domain/osv_mapping.py` — домен не импортирует репозитории — нулевая +- Ruling: R-5 (design #15) `EntityFilesState` (T2, в domain/checklist.py) отдельно от `unconfirmed_documents` (T8) — план дублирует владение — нулевая +- Ruling: R-6 шум плана (`requirements-dev.txt`) не трогаем — нулевая +- Ruling: R-7 (design #16, implementer T1) колонка `counterparty_match_kind` последняя в ORM/DBML (ADD COLUMN + тест ordinal_position), а не после `counterparty_id` — механическое следствие миграции — нулевая (порядок колонок в DBML) + +## Tasks +Task 1: implemented (commits arm-core 52ae1b9..eaee3ec [be509f2 design rows, eaee3ec feat]; docs ab564c1); make check 902 passed, dbml-check empty, migration-cycle 12 passed, integration 448 passed; review pending +O-1: workspace /Users/andrey.epifancev/projects/awp-ca/CLAUDE.md (не git) ссылается на v5.15 — править при закрытии среза (T1 concern 2) +Task 1: minor T1.1 (btrim vs strip в backfill NAME, косметика; SQL предписан брифом): deferred; T1.2 (factories.py не тронут, **overrides покрывает): rejected — фабрика уже принимает поле +Task 1: complete (commits 52ae1b9..eaee3ec, docs ab564c1, review clean) +Task 2: dispatched (sonnet, BASE eaee3ec) +Task 2: implemented (commit d3c37db; make check 907 passed, integration 466 passed); concerns T2.c1-c5 noted (c5 -> Task 6); review pending +Task 2: minor T2.1 (слабый smoke-тест test_repository_returns_selectable_models): deferred; T2.2 (ручное создание версии вместо make_version): deferred; T2.3 (`* 1`, async _osv_rows): deferred; T2.4 (тавтологичный assert в test_factories): deferred; T2.5 (make_confirmed_osv: ключи account_code/row_number в rows → TypeError): deferred; T2.6 (load_osv_rows: DEPARTMENT/OTHER/CURRENCY/QUANTITY не покрыты тестом): deferred; T2.7 (OsvFilters несёт limit/offset, план T10 — отдельный OsvPageParams → свести в T10): carried to Task 10; T2.8 (Literal ChecklistCellStatus/CounterpartyType в schemas vs доменные enum T8/T9 — не дублировать): carried to Task 8/9 +Task 2: complete (commits eaee3ec..d3c37db, review clean) +Task 3: implemented (commit 9411d9d; make check 916 passed, integration 486 passed); review pending +- Ruling: R-8 (design #17 — внести в design.md, pending) коды 404 `CONSOLIDATION_NOT_FOUND`/`VERSION_NOT_FOUND` реализуются как сообщение при коде `NOT_FOUND` (конвенция Срезов 1–2c: `NotFoundError` с фиксированным кодом, как `COMPANY_GROUP_NOT_FOUND`) — единообразие с принятыми срезами; спека говорит «код» — если владелец хочет отдельные коды, меняется конструктор ошибки +- Ruling: R-9 (design #18 — pending) решения T3: `in_work_only` убирает период без версий; невидимая/несуществующая ГК в списке → 404; невидимая/неизвестная ГСК → 404 `RELATED_GROUP_NOT_FOUND`, видимая ГСК чужой ГК или `kind=gsz` → 422 `RELATED_GROUP_MISMATCH` — естественное продолжение policy 404 — цена: расхождение с ожиданием фронта по пустому списку +Task 3: minor T3.1 (tests/factories.py FINISHED_STATUSES дублирует domain/policy.py): carried to Task 4 (policy.py); T3.2 (константа CONSOLIDATION_NOT_FOUND не используется): carried to Task 4; T3.3 (решения T3 не в design.md): fixed → pending design rows R-8/R-9 (внести в Task 5); T3.4 (_card raises VERSION_NOT_FOUND при нарушении целостности): rejected — недостижимо по FK +Task 3: complete (commits d3c37db..9411d9d, review clean) +Task 4: implemented (commit b2fa590; make check 934 passed, integration 529 passed; minors T3.1/T3.2 closed in-commit); review pending. Pending design rows (T4 decisions a–f, see task-4-report.md) +Task 4 review: I-1/T4.1 (дубль _lock_out) — Important, fix round 1; T4.2 (общий пролог версии → хелпер): carried to Task 5; T4.3 (осиротевший комментарий factories), T4.4 (слабый assert ip/ua), T4.5 (docstring Review Focus 4 тест не проверяет FOR UPDATE): fix round 1 +Task 4: fix round 1/5 dispatched (resumed implementer); design rows #17–#24 вносит implementer в этом же коммите +Task 4: fix round 1/5 (5 addressed, 0 open — T4.1,T4.3,T4.4,T4.5 + design rows #17–#24; commits b2fa590..e317fe5); out-of-scope: строка #24 частично дублирует #3 (косметика, deferred) +Task 4: complete (commits 9411d9d..e317fe5, review clean). design.md заполнен до #24; дальше исполнители добавляют свои решения со следующими номерами (#25+). +Task 5: implemented (commit 90b7bd5; integration 551 passed); design rows #25–#29; review pending +Task 5: minor T5.1 (rowcount type: ignore дублирует _rowcount из file_status.py): carried to Task 6 (функция переписывается); T5.2 (N DELETE в цикле по удалённым ЮЛ): deferred; T5.3 (неиспользуемая переменная other_entities в тесте): deferred; ⚠ spec-сценарий «ячейка CONFIRMED + consolidation_document после PUT members» и гонка members ∥ confirm — закрываются в Task 6/8 (carry to Task 6: интеграционный тест подхвата через PUT …/members) +Task 5: complete (commits e317fe5..90b7bd5, review clean) +Task 6: implemented (commit 36bc966; make check 944 passed, integration 571 passed; mutants confirmed); design rows #30–#36; review pending (focus: autoreplace ∥ PUT members deadlock test missing) +Task 6 review: T6.0 Important (нет двухсессионного теста автозамена ∥ подхват) — fix round 1; T6.1 (late import transition, design #36): deferred (follow-up: вынести transition/OBJECT_TYPE/release_active_lock в листовой модуль); T6.2 (слабые тесты latest/tie-break), T6.3 (no-op assert статуса), T6.4 (дубль lookup группы), T6.5 (формулировка design #30): fix round 1 +Task 6: fix round 1/5 dispatched (resumed implementer ad2f118342bb9af15, opus) +Task 6: fix round 1/5 (5 addressed, 0 open — T6.0,T6.2,T6.3,T6.4,T6.5; commits 36bc966..0e1fbd8); T6.1 deferred. O-2: R-3 дедлок через API недостижим (подхват привязывает только уже подтверждённые); оставлено как страховка (design #32) +Task 6: complete (commits 90b7bd5..0e1fbd8, review clean); полный `pytest -m "integration and not e2e"` после раунда 1 — контроллер гоняет (/tmp/slice4-t6-full.log) +Task 6 post-fix full regression (controller run): 574 passed (integration and not e2e, 213s). Task 7 dispatched (sonnet, BASE 0e1fbd8) +Task 7: implemented (commit 1908288; make check 944 passed; full integration 595 passed); design rows #37–#42; review pending +Task 7 review: T7.0 Important (тест хука не защищает надмножество R-2) — fix round 1; T7.1 (сбой перехода после загрузки → 500 и откат файлов) — Ruling R-10; T7.2 (второе соединение, пул), T7.5 (окно: ЮЛ убрали из периметра) — fix round 1 (формулировка design #39); T7.3 (порядок проверок лога), T7.4 (дубль TransactionFactory) — fix round 1; N+1 session.get(Consolidation) в хуке: deferred +- Ruling: R-10 (design #43) сбой перехода DOCUMENTS_PROCESSING после загрузки перехватывается и логируется, файлы остаются принятыми (201) — приём файлов важнее побочного статуса — версия может остаться INITIATED +Task 7: fix round 1/5 dispatched (resumed implementer a62187d68a65a967c) +Task 7: fix round 1/5 (6 addressed, 0 open — T7.0,T7.1/R-10,T7.2,T7.3,T7.4,T7.5; commits 1908288..4cc08cc); N+1 session.get(Consolidation) deferred +Task 7: complete (commits 0e1fbd8..4cc08cc, review clean) +Post-fix full regression Task 7 (controller): 597 passed. Task 8 dispatched (sonnet, BASE 4cc08cc) +Task 8: implemented (commit f46cf2f; make check 971, full integration 603); design rows #44–#49; review pending +Task 8: minor T8.1 (замена _osv_coverage на select_sources — не drop-in: Gap не несёт present document_ids, и missing_quarters=() при нулевом покрытии (design #46); нужен адаптер): carried to Task 9; T8.2 (нет теста, что fits-фильтр в сервисе работает — неподходящий квартал/не-член → MISSING): deferred (final review: triage); T8.3 (нет API-теста ERROR/58.03): deferred; T8.4 (CellStatus(best.status.value) по строке → явная таблица): carried to Task 9 (checklist.py правится); T8.5 (_QUARTER_ORDER в трёх местах → один QUARTER_ORDER в consolidation_periods): carried to Task 9; T8.6 (document_ids без кандидатов, design #47): rejected — в спеке «привязанные документы» +Task 8: complete (commits 4cc08cc..f46cf2f, review clean) +Task 9: implemented (commit d5a317a; make check 1030 unit; integration 603); design rows #50–#56; review pending (opus) +Task 9: minor T9.a (account_sort_key: isdigit()+int() падает на "6²"/"①" → 500 свода на сырых account_code): carried to Task 10 (однострочник isdecimal + тест); T9.b (design #50: квартальный Q1 при версии Q1 склеивается — дубли суммируются, None→0, is_cumulative=False, тогда как нарастающий Q1 отдаётся как есть; спека: «равнозначен нарастающему»): deferred — на подтверждение владельцу (в "Rulings I made"); T9.c (design #54: при нарушении инварианта R-1 select_sources возвращает все документы, суммы квартальных тихо удваиваются; страховка warnings/лог): deferred; T9.d (мелкие пробелы тестов: расхождение id между дублями в последнем квартале, резолвер вернул id вне справочника, stored id при match_kind=None): deferred; T9.e (src/__init__ импортирует app → import src.domain тянет приложение — особенность шаблона): rejected, не вина задачи +Task 9: complete (commits f46cf2f..d5a317a, review clean) +Task 10: implemented (commit a2c2135; make check 1040; full integration 614; perf 5400 rows 0.27s/8 SELECTs); design rows #57–#61; review pending +Task 10: minor T10.1 (IN(ids) vs asyncpg 32767 params; = ANY(:ids), выбирать лишь нужные колонки; design #60): deferred; T10.2 (потолок perf-теста 5 с при цели < 2 с — комментарий; debug print noqa T201): deferred; T10.3 (нет проверки `opening_debit is None` у нарастающей строки на уровне API; слабый тест account_sort_key): deferred; T10.4 (quantize округляет молча при >2 знаках — невозможно для numeric(18,2)): rejected +Task 10: complete (commits d5a317a..a2c2135, review clean) +Task 11: implemented (commit e246531; make check 1043; full integration 640); design rows #62–#70; review pending (opus) +- Ruling: R-11 (design #62/#63, принято исполнителем T11) «строка версии» в ручном сопоставлении/автоприменении сужена до строк свода (group_type=COUNTERPARTY, data_type BU/SUM) привязанных ОСВ-документов; NU/ACCOUNT → 404 ENTRY_NOT_FOUND и в автоприменение не входят — §3 п. 2 и R-3 Среза 2c (NU не участвует) — цена: NU-строки того же контрагента остаются без counterparty_id; буквальное чтение спеки шире. На подтверждение владельцу. +Task 11: minor T11.1 → R-11; T11.2 (row:null в MapCounterpartyOut — отразить в docs/api-contract-slice-4.md, выровнять формулировку #62): carried to Task 14; T11.3 (aliases.remove без блокировки → фантомное событие при гонке с move_alias; класс гонки существовал): deferred (follow-up); T11.4 (тест «без регистра» не фиксирует регистр): deferred; T11.5 (нет тестов: несколько владельцев алиаса при move_alias/ALIAS_TAKEN; алиас и у target, и у другого): deferred; T11.6 (дубль имени индекса ALIAS_UNIQUE_INDEX и логики AliasService.add/remove): deferred; T11.7 (ответ строится полным _build под блокировками): deferred; T11.8 (README каталог ошибок): carried to Task 14; T11.9 (новый алиас не снимает неоднозначность резолва на лету с тёзкой по имени — вопрос ТЗ DOC-221278): carried to Task 14 (07-open-questions) +Task 11: complete (commits a2c2135..e246531, review clean) +Task 12: implemented (commit 8e82c2c; make check 1045; full integration 661); design rows #71–#76; review pending +Task 12 review: T12.1 Important (квадратичное время экспорта: sheet.max_row в цикле) — fix round 1; T12.2 Important (формульная инъекция из внешних строк + 500 на управляющих символах) — Ruling R-12; T12.3 (порог точности #73: ≈10^13, не 10^15), T12.4 (мёртвый параметр entity_names) — Ruling R-13, T12.5 (нет теста None-сумм), T12.6 (model_dump(mode="json") в #76): fix round 1; M-4 (run_in_threadpool): fix round 1 +- Ruling: R-12 (design #77) текстовые ячейки экспорта: вырезать ILLEGAL_CHARACTERS и принудительно data_type "s" (никаких формул из внешних данных) — файлы внешние, аналитики открывают в Excel — текст с ведущим '=' не вычисляется (желаемо) +- Ruling: R-13 (design #78) параметр entity_names убран из build_workbook (в плане предписан, не используется; сигнатура плана исправлена) — YAGNI — если нужен словарь имён, вернуть +Task 12: fix round 1/5 dispatched (resumed implementer aa5d6fd0aa1c6a31f) +Task 12: fix round 1/5 (7 addressed, 0 open — T12.1–T12.6, M-4; commits 8e82c2c..5b3b101); T12 export timing 6000 rows: 8.44 s → 0.46 s +Task 12: complete (commits e246531..5b3b101, review clean); последний полный прогон integration: 661 passed (до fix round), после — 33 passed в целевых +Task 13: implemented (commit af91643, тест под xfail(strict=True) — найден реальный дефект Срезов 4): golden ОСВ Q1 имеют is_cumulative=TRUE (Q2/Q3 — false); `fits` привязывает нарастающий только за квартал версии, `select_sources` не считает нарастающий Q1 квартальным Q1 → для версии Q3 привязано 17/22, РБ 60/62 и Счастливочная 60/62/76 уходят в gaps [Q1], rows_total=0. Временный патч (task-13-experiment-fix.diff) делает сценарий зелёным (1385 строк свода, 1381 unmapped; период сверен независимо по 5 контрагентам РБ). +- Ruling: R-14 (design #81) «Q1-эквивалентность»: документ за Q1 (period_quarter=Q1) эквивалентен и нарастающему, и квартальному — нарастающий за Q1 покрывает Q1 в версиях Q2+ как квартальный Q1 (парсер помечает ОСВ за Q1 is_cumulative=true, т.к. период с начала года = Q1); `fits`: документ Q1 подходит любой версии года; `select_sources`: документ Q1 любого флага — квартальный источник Q1 (для версии Q1 поведение прежнее: как есть/склейка по флагу); ключ R-1 для Q1 не различает флаги (позднейший confirmed_at побеждает) — иначе свод на реальных данных пуст — цена: если владелец считает Q1-нарастающий иным — но по определению периода это одно и то же +- Ruling: R-15 (design #80, принято исполнителем T13) сценарий «1 + propagated» уточнён: propagated считает записи osv_entry, свод склеивает кварталы → unmapped_total падает на число СКЛЕЕННЫХ строк, не на 1+propagated; тест сверяет обе величины отдельно; формулировку spec уточнить при архивации (человеку) +Task 13: STATUS открыт — fix round (дефект R-14) через резюмирование исполнителя Task 9 +Task 13 fix: commit d3c4964 (R-14 реализован: effective_cumulative; e2e тест зелёный: 22 документа привязано, rows_total 1385, unmapped 1381; full integration 668); concern → Ruling R-14a (design #81 дополняется): Q1-эквивалентность только для ОСВ (ББ/ОФР за Q1 не подходят версиям Q2–Q4) — ББ/ОФР Q1 = баланс на конец Q1 — цена: если и ББ/ОФР должны подхватываться, снять условие; fix R-14a dispatched +Task 13 review (range 5b3b101..0bfe9ee, opus): Approved, Critical/Important нет; minors T13.M1 (spec THEN «1+propagated» ложен на реальных данных), M2 (формулировка #80), M3 (зафиксировать точные значения 1385/1381/4/17 и набор gaps), M4 (кандидаты не вызываются — docstring), M5 (MANUAL/alias_created/EXTERNAL до), M6 (ссылка на #79 вместо #80), M7 (порядок assert NEEDS_CHECK), M8 (docstring effective_cumulative: R-14a живёт в вызывающем), M10 (текст отметок плана): fix round 1 (resumed implementer a9283a17cfe22228e); M9 (дубль цикла map в test_mapping_golden — оправдан замером): rejected +Task 13: R-14a commit 0bfe9ee; полный integration 670 passed (после R-14a); fix round 1/5 dispatched (doc/test-only) +Task 13 fix round 1: commit a5cde2a (M1–M3, M5–M8, M10 сделаны; M4 урезан: recommended=true на реальных данных не получается — в файлах РБ имя «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ», рекомендация = точное совпадение нормализованного имени/алиаса; тест проверяет наличие кандидата с типом GSK; сценарий «Рекомендуемая Счастливочная» покрыт на синтетике) +O-3: на реальных фикстурах `recommended` для «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» не срабатывает (точное совпадение normalize_alias) — вопрос к продукту/ТЗ (нечёткая рекомендация), в Task 14 → 07-open-questions +O-4: match_kind склеенной строки берётся из последнего квартала (D-3, #8): сопоставление записи раннего квартала даст PROPAGATED в ответе, хотя в БД одна MANUAL; фронту слать последнюю entry_id строки или осознанно показывать способ строки — в Task 14 → docs/api-contract-slice-4.md +Task 13: fix round 1/5 (all addressed; commit a5cde2a); full chain af91643,d3c4964,0bfe9ee,a5cde2a +Task 13: complete (commits 5b3b101..a5cde2a, review clean); последний полный integration (после R-14a): 670 passed +Task 14: implemented (arm-core 2c6908a, docs 9246a61); check-full EXIT 0 (unit 1106, integration 670, fixtures 15 passed, e2e 1 skipped, docker OK, web OK); design #82–#85; OQ-124…129; review pending +Task 14 review: Approved (Critical/Important нет); minors T14.1 (OQ-121/03-architecture: замер 0,27 с относится к синтетическому тесту, не к фикстурам РБ), T14.2 (счёт design в 09-delivery-plan), T14.5 (пробелы в api-contract: OsvSummaryOut, условия PROPAGATED, 422 takeover_reason >1000), T14.6 (формулировки, design #85) — fix round 1 (resumed a35a235509fda036c); T14.3 (неточность отчёта: fixtures 14 passed + 1 skipped) — исправляется в отчёте; T14.4 (имя модели в трейлере): rejected +Task 14: fix round 1/5 dispatched +Task 14: fix round 1/5 (all addressed; arm-core 98922cc, docs 308b493); T14.3 inline: отчёт содержит поправку в конце — cosmetic +Task 14: complete (arm-core 2c6908a..98922cc, docs 9246a61..308b493, review clean). check-full: EXIT 0 (log check-full.log) + +## Final whole-branch review (opus, 714832e..98922cc): Ready to merge — With fixes (Critical нет) +- I-1: правило «кандидат подходит версии (fits)» в чек-листе не защищено тестом (мутант без fits в consolidations.py:206 выжил на 127 тестах) = T8.2 → fix wave (один исполнитель) +- M-2: текст спеки расходится с кодом (коды 404 CONSOLIDATION_NOT_FOUND/VERSION_NOT_FOUND/ENTRY_NOT_FOUND/LEGAL_ENTITY_NOT_FOUND = код NOT_FOUND с текстом; «тройка» в documents-api «Подхват» (R-1); «перечитывает под FOR UPDATE» (R-3: NO KEY UPDATE)) → fix wave (только текст спеки) +- M-3: переход после загрузки коммитится раньше транзакции запроса (версия может остаться DOCUMENTS_PROCESSING без файлов при сбое коммита) → fix wave: дописать риск в design #39/#43; код deferred +- M-4: нет теста advisory-блокировки алиаса (design #66; aliases.add ∥ сопоставление) → deferred (follow-up) +- M-5: неверный docstring repositories/consolidations.py:131-132 («ЮЛ без ГК не может быть членом»; после withdraw/move может — такое ЮЛ блокирует PUT members с ENTITY_NOT_IN_GROUP) → fix wave: docstring; OQ — deferred +- M-6: нет верхних границ входов (MembersIn.legal_entity_ids, StatusIn.comment, OsvFilters.q → 500 на огромном списке; lock_visible_version берёт FOR UPDATE до проверки видимости) → deferred +- M-7: общие строки osv_entry меняются и для не-«в работе» версий (VERIFIED/маршрут) → deferred до Среза 7 (OQ-123) +- M-8: Makefile DBML ?= data-model-v5.16.dbml: в основном checkout make dbml-check упадёт, пока не смержен docs PR #20 → порядок мержа: docs PR раньше/вместе — в описание PR (человеку) +- Ledger triage финального ревью: T1.1,T2.1–T2.6,#24 дубль,T5.2,T5.3,T6.1,T7 N+1,T8.3,T9.b,T9.c,T9.d,T10.1–T10.3,T11.3–T11.7 — may stay deferred; T8.2 — must-fix (= I-1); O-1 — при закрытии среза +Final fix wave (one dispatch, resumed a9d16bc67ead11c8b): commit 42c2531 — I-1 fixed (7 тестов чек-листа; мутант без fits роняет 3), M-2 fixed (спеки: 404=NOT_FOUND+сообщение, ключ привязки R-1/R-14, блокировки R-3), M-3 fixed (design #39/#43 — риск; код deferred), M-5 fixed (docstring); scoped re-review: all addressed, no new breakage. Out-of-scope observations re-review: formulation «VERSION_NOT_FOUND» в спеках = имя константы, а не текст сообщения (косметика, deferred); docstring lock_versions_for_entity слегка преувеличивает про PUT members (deferred); нет разделителя в design #39/#43 перед добавленным предложением (deferred) +Final review: CLEAN (после fix wave) + +## Итоговые прогоны +- Финальный (после fix wave 42c2531): make check — 1106 passed, 678 deselected; pytest -m "integration and not e2e" — 677 passed, 1107 deselected (4:20). +- make check-full на 98922cc: EXIT 0 — unit 1106 passed; integration 670 passed; fixtures 14 passed + 1 skipped (e2e без профиля services); e2e 1 skipped; dbml-check v5.16 OK; check-infra OK; docker build OK; web-check OK. + +## Приложение: pre-flight рулинги (preflight-rulings.md) + +# Pre-flight рулинги контроллера (Срез 4) — обязательны для задач, на которые указывает диспатч + +Спека главнее плана; здесь — расхождения и дыры, найденные до старта. Для каждого рулинга в `openspec/changes/slice-4-consolidation/design.md` +(раздел «Decisions made without discussion») добавляется строка с номером из скобок (их добавляет исполнитель Task 1 одним коммитом). + +## R-1 (design #11) — ключ «тройки» при привязке включает признак нарастающего +Спека (documents-api «Подтверждение», «Подхват») заменяет привязанный документ той же тройки `(document_type, osv_account_code, period_quarter)`, а сценарий +consolidated-osv «Нарастающий рядом с квартальными» требует, чтобы квартальный Q3 и нарастающий Q3 одной пары (ЮЛ, счёт) были привязаны одновременно. Тройка без +признака уничтожила бы второй. РЕШЕНИЕ: ключ замены/выбора «самого позднего `confirmed_at`» = `(document_type, osv_account_code, period_quarter, cumulative)`, +где `cumulative = (is_cumulative is not False)` (NULL → нарастающий, как D-5/решение №10). Цена ошибки: если владелец хотел «один документ на квартал» — нарастающий +и квартальный вытесняли бы друг друга (меняется только ключ в `bind_confirmed`/`on_document_confirmed`). + +## R-2 (design #12) — гонки потерянной привязки: блокировка надмножества версий +`lock_versions_for_entity(entity_id, statuses)` блокирует `FOR UPDATE ORDER BY id` НАДМНОЖЕСТВО: версии «в работе», где ЮЛ — член ИЛИ +`consolidation.company_group_id = legal_entity.company_group_id` ЮЛ; затем вторым запросом (новый снимок READ COMMITTED) возвращает только версии, где ЮЛ — член. +Иначе confirm ∥ `PUT …/members` теряет привязку (confirm не видит незакоммиченного нового члена и не ждёт версию), а автозамена ∥ подхват оставляет привязку заменённого +документа. Те же правила: `on_document_confirmed`, `on_documents_superseded` (по ЮЛ документов), `on_documents_mapped` (Task 7). Гонка «создание версии ∥ подтверждение»: +новая версия невидима до commit — Task 6 закрывает её (предлагаемый способ: `create` берёт `pg_advisory_xact_lock` по ГК, `lock_versions_for_entity` берёт тот же lock +первым; порядок «файл → документ → advisory ГК → версии»; `PUT …/members` advisory не берёт). Любой другой способ допустим, если есть тест на двух сессиях. +Цена ошибки: тихо потерянная привязка документа (редко, чинится повторным добавлением ЮЛ в периметр). + +## R-3 (design #13) — `FOR NO KEY UPDATE` на документах/файлах в confirm и автозамене +Вставка `consolidation_document` (FK на `document`) берёт на строку документа `FOR KEY SHARE`, а `lock_document`/`lock_file_documents` — `FOR UPDATE`: подтверждение +(держит документ, ждёт версию) ∥ правка периметра/подхват (держит версию, вставляет привязку на тот же документ) взаимоблокируются. РЕШЕНИЕ: `lock_document`, +`lock_file_documents` (и `lock_file`, если он участвует в тех же циклах) переводятся на `with_for_update(key_share=True)` (FOR NO KEY UPDATE; блокирует других писателей так же, +не конфликтует с KEY SHARE). Тест — на двух сессиях, таймаут 5 с. Цена ошибки: нулевая для остальных путей (слабее по FK-ссылкам, строже по обновлениям не меняется). + +## R-4 (design #14) — `Resolution` живёт в домене +Task 1 по плану кладёт `Resolution` в `repositories/counterparties.py`, а Task 9 (`domain/consolidated_osv.py`, mypy strict, без ORM/репозиториев) нуждается в нём в `Resolver`. +Домен не импортирует репозитории. РЕШЕНИЕ: `Resolution` (frozen dataclass `entity_id`, `kind`) определяется в `src/domain/osv_mapping.py`; `repositories/counterparties.py` +импортирует и реэкспортирует. Цена: нулевая (перенос определения). + +## R-5 (design #15) — разделение `EntityFilesState` и документов-кандидатов между Task 2 и Task 8 +План описывает `entity_files_state` одновременно в Task 2 (создаёт) и Task 8 (модифицирует), а `domain/checklist.build` получает и `files_state`, и `unbound`. РЕШЕНИЕ: +Task 2 создаёт `src/domain/checklist.py` с одним frozen dataclass `EntityFilesState(has_in_progress: bool, last_file_status: FileStatus | None)` и +`repositories.entity_files_state(session, entity_ids) -> dict[uuid.UUID, EntityFilesState]` (с тестом); неподтверждённые подходящие документы отдельной функцией +`unconfirmed_documents(session, entity_ids, year) -> list[Document]` (RECOGNIZED/NEEDS_CHECK, файлы не SUPERSEDED, ОСВ/ББ/ОФР) — её добавляет Task 8 вместе с `rules_for`; +Task 8 дополняет `checklist.py` функцией `build` и остальными типами. Цена: нулевая. + +## R-6 — шум плана +Task 12: `api/requirements-dev.txt` не трогать. Прочие заглушки Task 3/5 (`bind_confirmed`, `checklist=[]`, прямое удаление привязок) заменяются в Task 6/8, как в плане. -- 2.47.2