Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
54 KiB
Срез 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): + enumcounterparty_match_kind(INN,NAME,ALIAS,MANUAL,PROPAGATED), +osv_entry.counterparty_match_kind(nullable; NULL ⇔counterparty_idNULL — проверяет код). Полей вне 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; остальное — 409TRANSITION_NOT_ALLOWED. - Правка версии (
PUT …/members,POST …/status,POST …/osv/entries/{id}/counterparty): право → видимость (ГК черезcompany_groups_visible) →ensure_version_editable(409VERSION_NOT_EDITABLE/LOCK_REQUIRED/LOCKED_BY_OTHER). Чтение, загрузка и подтверждение от блокировки не зависят. - Подходящесть документа версии (D-5):
legal_entity_id ∈ members,period_year = year,period_quarterне NULL;is_cumulativetrue/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 — строки (pydanticDecimal); в 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
- Один документ в двух версиях (две консолидации ГК за Q2 и Q3 одного года, квартальная ОСВ Q1 подходит обеим): подтверждение привязывает к обеим и двигает обе; ручное сопоставление в одной меняет
osv_entryдля обеих (OQ-123) — Task 6: тест «две версии», Task 11: тест «строка видна во второй версии сMANUAL». - Пара (ЮЛ, счёт) с нарастающим документом за Q2 при версии за Q3 — не подходит, не привязывается, не источник; с квартальными Q1–Q3 рядом — нарастающий за Q3 побеждает — Task 9: тесты.
- Ручное сопоставление строки, у которой имя из одних ОПФ (
normalize_aliasпуст): алиас не создаётся, строка связывается, автоприменение поstrip()имени всё равно работает — Task 11: тест. - Перехват блокировки во время правки периметра владельцем (две транзакции:
PUT …/membersподFOR UPDATEверсии иPOST …/lock {takeover_reason}): перехват ждёт блокировку версии, после неё правка владельца уже зафиксирована, его следующая правка — 409LOCKED_BY_OTHER— Task 4: тест на двух сессиях (committed_sessionmaker). - Экспорт с фильтром
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. enumcounterparty_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.md1 →[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 headkind =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, шаг резолва в маппинге(+ коммит в docsdocs: 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(joinconsolidation→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]иначе 409TRANSITION_NOT_ALLOWED(extra: from_status, to_status) →CANCELLEDбезcomment→ 422COMMENT_REQUIRED. -
ConsolidationLockService:take(version_id, data: LockIn) -> tuple[LockOut, bool created](lock_version→ видимость →status is CANCELLED→ 409VERSION_NOT_EDITABLE→active_lock: нет → insert (IntegrityError→ 409LOCKED_BY_OTHER), своя → вернуть, чужая без причины → 409LOCKED_BY_OTHER, с причиной → release + insert(taken_over_from,takeover_reason) +CONSOLIDATION_LOCK_TAKEOVER);release(version_id) -> None(своя →released_at, аудитCONSOLIDATION_LOCK after.released = true; чужая → 409NOT_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(иначе 422ENTITY_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_cumulativeNULL → нарастающий). -
async def bind_confirmed(session, *, version, consolidation, entity_ids) -> int— подхват:CONFIRMEDдокументы ЮЛ из файловstatus != SUPERSEDED,fits, на тройку(document_type, osv_account_code, period_quarter)— maxconfirmed_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)→ для подходящих: заменить тройку, insertConsolidationDocument, если статус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_valueFILE_REPLACE. -
Step 1: Тесты. Unit
fits: таблица из 8 случаев (член/не член, год, нарастающий Q2/Q3 при версии Q3, квартальный Q1/Q4,period_quarterNULL,is_cumulativeNULL). 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(queryconsolidation_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)(404VERSION_NOT_FOUND) →status ∈ IN_WORK_STATUSES(409VERSION_NOT_EDITABLE) → ЮЛ ∈members(422ENTITY_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_byNULL — без перехода и без ошибки. -
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 → ячейка ОСВ 60CONFIRMED, ОСВ 90MISSING; карточка содержит те же ячейки;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иначе 422QUERY_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(404ENTRY_NOT_FOUND) → ЮЛ (session.get(LegalEntity), 404LEGAL_ENTITY_NOT_FOUND) →normalized→ владельцы ≠ target:move_aliasfalse → 409ALIAS_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 overrideopenpyxl.*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) и ГСК из них; маппинг goldenrb/2024-Q1..Q3/*.jsonиschastl/2024-Q1..Q3/*.jsonчерезmap_file(реальныйCounterpartyIndex); подтверждение всех документов черезPOST /documents/{id}/confirm(ka_rf);POST /consolidations2024 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(маршрут → схема → коды ошибок → экран макета; выжимка из OpenAPIGET /openapi.json),openspec/changes/slice-4-consolidation/tasks.md2–4 →[x] -
Step 1: Проверить
GET /openapi.jsonна тестовом приложении (test_openapi_routes) — все маршруты консолидации сoperation_idиresponses. -
Step 2: Написать документы;
make check(линтер markdown, если есть); коммит в arm-coredocs(slice-4): README и CLAUDE.md по консолидации; коммит в docsdocs(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.