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

54 KiB
Raw Blame History

Срез 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.

  • 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.