plan(slice-4): план исполнения — 14 задач по спеке slice-4-consolidation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
6e5ab834ec
commit
52ae1b937d
277
docs/superpowers/plans/2026-10-06-slice-4-consolidation.md
Normal file
277
docs/superpowers/plans/2026-10-06-slice-4-consolidation.md
Normal file
@ -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 <модель исполнителя> <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.
|
||||||
Loading…
x
Reference in New Issue
Block a user