arm-core/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md
Andrey Epifancev 36bc966bf6 feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 04:57:15 +04:00

278 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Срез 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 <модель исполнителя> <noreply@anthropic.com>` и `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`.
- [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`).
---
### 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`.
- [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): репозиторий, схемы и фабрики консолидаций`.
---
### 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")`.
- [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): создание консолидации, список по ГК, карточка версии`.
---
### 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).
- [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`.
---
### 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, карточка).
- [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): редактирование периметра версии с подхватом и отвязкой документов`.
---
### 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`.
- [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): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене`.
---
### 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.