From 7194a55c159bb524b3db7d1472111224f9c69d88 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 29 Oct 2026 15:58:25 +0300 Subject: [PATCH 01/10] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20vgo?= =?UTF-8?q?=5Fassignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0011_vgo_assignment.py | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 api/alembic/versions/0011_vgo_assignment.py diff --git a/api/alembic/versions/0011_vgo_assignment.py b/api/alembic/versions/0011_vgo_assignment.py new file mode 100644 index 0000000..1339b0b --- /dev/null +++ b/api/alembic/versions/0011_vgo_assignment.py @@ -0,0 +1,60 @@ +"""Хранение назначения строк ОСВ правилам по версии консолидации.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op +from src.models.base import SCHEMA + +revision = "0011_vgo_assignment" +down_revision = "0010_dbml_v5_23" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "vgo_assignment", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("consolidation_version_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "statement_type", + postgresql.ENUM("balance", "income", name="statement_type", schema=SCHEMA, create_type=False), + nullable=False, + ), + sa.Column("osv_entry_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("rule_id", postgresql.UUID(as_uuid=True)), + sa.Column("action", sa.String(), nullable=False), + sa.Column("is_exclude", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("created_by", postgresql.UUID(as_uuid=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_by", postgresql.UUID(as_uuid=True)), + sa.Column("updated_at", sa.DateTime(timezone=True)), + sa.ForeignKeyConstraint( + ["consolidation_version_id"], [f"{SCHEMA}.consolidation_version.id"], name="fk_vgo_assignment_version" + ), + sa.ForeignKeyConstraint(["osv_entry_id"], [f"{SCHEMA}.osv_entry.id"], name="fk_vgo_assignment_entry"), + sa.ForeignKeyConstraint(["rule_id"], [f"{SCHEMA}.elimination_rule.id"], name="fk_vgo_assignment_rule"), + sa.ForeignKeyConstraint(["created_by"], [f"{SCHEMA}.app_user.id"], name="fk_vgo_assignment_created_by"), + sa.ForeignKeyConstraint(["updated_by"], [f"{SCHEMA}.app_user.id"], name="fk_vgo_assignment_updated_by"), + sa.CheckConstraint("action IN ('ASSIGN', 'UNASSIGNED')", name="ck_vgo_assignment_action_values"), + sa.CheckConstraint( + "(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)", + name="ck_vgo_assignment_action_rule_consistency", + ), + schema=SCHEMA, + ) + op.create_index( + "uq_vgo_assignment_version_type_entry", + "vgo_assignment", + ["consolidation_version_id", "statement_type", "osv_entry_id"], + unique=True, + schema=SCHEMA, + ) + op.create_index("ix_vgo_assignment_osv_entry_id", "vgo_assignment", ["osv_entry_id"], schema=SCHEMA) + + +def downgrade() -> None: + op.drop_index("ix_vgo_assignment_osv_entry_id", table_name="vgo_assignment", schema=SCHEMA) + op.drop_index("uq_vgo_assignment_version_type_entry", table_name="vgo_assignment", schema=SCHEMA) + op.drop_table("vgo_assignment", schema=SCHEMA) -- 2.47.2 From 086050cc87a8136b0a396afa4f91817f52d4fa77 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Thu, 29 Oct 2026 19:13:58 +0300 Subject: [PATCH 02/10] =?UTF-8?q?feat/corrections-back:=20=D0=9A=D0=BE?= =?UTF-8?q?=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B0=D0=B2=D0=BA=D0=B0=D0=BC=20=D0=B2=20vgo?= =?UTF-8?q?=5Fassignments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- README.md | 40 +- ...1_vgo_assignment.py => 0011_dbml_v5_24.py} | 0 api/src/api/dependencies/consolidations.py | 23 ++ .../api/endpoints/consolidations/__init__.py | 2 + .../endpoints/consolidations/corrections.py | 77 ++++ api/src/domain/corrections.py | 41 +++ api/src/repositories/corrections.py | 91 +++++ api/src/schemas/corrections.py | 123 +++++++ api/src/services/correction_source.py | 253 +++++++++++++ api/src/services/corrections.py | 242 +++++++++++++ api/src/services/corrections_export.py | 96 +++++ api/src/services/corrections_view.py | 84 +++++ api/src/services/corrections_write.py | 66 ++++ api/tests/test_api_corrections.py | 308 ++++++++++++++++ api/tests/test_api_corrections_mutations.py | 342 ++++++++++++++++++ api/tests/test_correction_source.py | 74 ++++ api/tests/test_corrections_domain.py | 47 +++ api/tests/test_corrections_export.py | 135 +++++++ api/tests/test_corrections_golden.py | 45 +++ api/tests/test_openapi_routes.py | 6 + .../corrections-api-stage-one/.openspec.yaml | 2 + .../corrections-api-stage-one/design.md | 43 +++ .../corrections-api-stage-one/proposal.md | 33 ++ .../specs/corrections-api/spec.md | 80 ++++ .../corrections-api-stage-one/tasks.md | 39 ++ openspec/config.yaml | 4 +- 27 files changed, 2287 insertions(+), 11 deletions(-) rename api/alembic/versions/{0011_vgo_assignment.py => 0011_dbml_v5_24.py} (100%) create mode 100644 api/src/api/endpoints/consolidations/corrections.py create mode 100644 api/src/domain/corrections.py create mode 100644 api/src/repositories/corrections.py create mode 100644 api/src/schemas/corrections.py create mode 100644 api/src/services/correction_source.py create mode 100644 api/src/services/corrections.py create mode 100644 api/src/services/corrections_export.py create mode 100644 api/src/services/corrections_view.py create mode 100644 api/src/services/corrections_write.py create mode 100644 api/tests/test_api_corrections.py create mode 100644 api/tests/test_api_corrections_mutations.py create mode 100644 api/tests/test_correction_source.py create mode 100644 api/tests/test_corrections_domain.py create mode 100644 api/tests/test_corrections_export.py create mode 100644 api/tests/test_corrections_golden.py create mode 100644 openspec/changes/corrections-api-stage-one/.openspec.yaml create mode 100644 openspec/changes/corrections-api-stage-one/design.md create mode 100644 openspec/changes/corrections-api-stage-one/proposal.md create mode 100644 openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md create mode 100644 openspec/changes/corrections-api-stage-one/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index 7ba1e06..d85d904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.23.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.24.dbml` (текущая версия; v5.24 — назначения ВГО; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23` + `0011_dbml_v5_24`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и diff --git a/README.md b/README.md index 20f9316..846878b 100644 --- a/README.md +++ b/README.md @@ -1142,7 +1142,7 @@ email без учёта регистра проверяет только сер | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.23.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.24.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -1186,15 +1186,39 @@ docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services st Образ собирается из `../tabular-parser` (main соседнего репозитория workspace). -**Перед e2e остановите другие экземпляры ядра, подписанные на `files.tabular_parsed`** (контейнер `arm-core` из профиля -`services`, локальный `make run`/`run_server.py` с `NATS_ENABLED=true`): тест подписывается на реальный subject в боевой -queue group `arm-core`, подписчики группы получают сообщения по очереди (round-robin), и ack/результат парсера уйдут -чужому экземпляру (он отбросит их как `unknown_job`, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только -`tabular-parser` (`--no-deps`), как в команде выше. +### Основа таблицы корректировок (AURORA-1540, в работе) + +Текущая задача не реализует ручное назначение, перенос или исключение строк ОСВ. + +Чистая функция `domain/corrections.py` реализует контроль готового агрегата: при отклонении не более 5 % +справочная сумма элиминации равна меньшей стороне. Соседняя задача записывает готовые назначения в +`vgo_assignment`, а API корректировок читает их и группирует строки по правилу и паре компаний. Этот API +не выбирает правила, не разносит строки ОСВ и не меняет назначения. + +Подключены маршруты под `/api/v1/consolidations/versions/{version_id}`: + +- `GET /corrections?statement_type=balance` — готовые агрегаты, исходные строки и сохранённые корректировки; +- `PUT /corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}?statement_type=balance` — полная замена корректировки со статусом `APPLIED` и аудит `CORRECTION_CREATE/EDIT`; +- `GET /corrections/export?statement_type=balance` — один лист XLSX: агрегат и следующие за ним раскрываемые строки ОСВ; событие `EXPORT`; + +GET требует `CONSOLIDATION_VIEW` и не меняет данные. Импорт XLSX, создание и изменение `vgo_assignment`, +разнесение ОСВ, ручные переносы, КФО, ОФР и UI в этот этап не входят. + +Пример сохранения корректировки (статьи должны принадлежать привязанной форме соответствующей компании): + +```json +{"debit":[{"line_code":"1230","amount":"100.00"}],"credit":[{"line_code":"1520","amount":"90.00"}],"comment":"Причина расхождения","expected_version_no":null} +``` + +При повторном сохранении передаётся текущий `correction.version_no`; несовпадение даёт 409 +`CORRECTION_VERSION_CONFLICT`. Передаются все статьи обеих сторон: первая основная, остальные дочерние глубины 1. +Одна пустая сторона допустима, обе пустые — 422 `EMPTY_CORRECTION`; пустой комментарий — `COMMENT_REQUIRED`. +Коды включают исходные строки и шаблон редакции привязанной формы; код чужой компании — `INVALID_LINE_CODE`. +Сохранение не изменяет исходные суммы агрегата; ответ показывает контроль распределения. ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.24.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -1301,7 +1325,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных; + [`../docs/model/data-model-v5.24.dbml`](../docs/model/data-model-v5.24.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/alembic/versions/0011_vgo_assignment.py b/api/alembic/versions/0011_dbml_v5_24.py similarity index 100% rename from api/alembic/versions/0011_vgo_assignment.py rename to api/alembic/versions/0011_dbml_v5_24.py diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 600689a..a86bc41 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -13,6 +13,8 @@ from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService +from src.services.correction_source import CorrectionAggregateSource, VgoAssignmentCorrectionSource +from src.services.corrections import CorrectionService from src.services.counterparty_mapping import CounterpartyMappingService from src.services.elimination_rules import EliminationRuleService from src.services.intercompany_matrix import IntercompanyMatrixService @@ -97,3 +99,24 @@ def get_intercompany_matrix_service( IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)] + + +def get_correction_source(session: SessionDep) -> CorrectionAggregateSource: + """Агрегаты из готовых назначений ОСВ, сформированных соседней задачей.""" + return VgoAssignmentCorrectionSource(session) + + +CorrectionSourceDep = Annotated[CorrectionAggregateSource, Depends(get_correction_source)] + + +def get_correction_service( + session: SessionDep, + scope: Annotated[VisibilityScope, Depends(get_scope)], + principal: Annotated[Principal, Depends(current_user)], + source: CorrectionSourceDep, +) -> CorrectionService: + """Чтение агрегатной таблицы с доступом текущего пользователя.""" + return CorrectionService(session, scope, principal, source) + + +CorrectionServiceDep = Annotated[CorrectionService, Depends(get_correction_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 934c11e..6878b0d 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from src.api.endpoints.consolidations import ( checklist, + corrections, elimination_rule, export, intercompany_matrix, @@ -23,6 +24,7 @@ router.include_router(locks.router) router.include_router(status.router) router.include_router(members.router) router.include_router(checklist.router) +router.include_router(corrections.router) router.include_router(osv.router) router.include_router(export.router) router.include_router(mapping.router) diff --git a/api/src/api/endpoints/consolidations/corrections.py b/api/src/api/endpoints/consolidations/corrections.py new file mode 100644 index 0000000..5233897 --- /dev/null +++ b/api/src/api/endpoints/consolidations/corrections.py @@ -0,0 +1,77 @@ +"""Первый этап API агрегатной таблицы: чтение и XLSX-выгрузка.""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep, CorrectionServiceDep +from src.common.filenames import content_disposition +from src.models.enums import StatementType +from src.schemas.corrections import CorrectionAggregateOut, CorrectionIn, CorrectionTableOut + +router = APIRouter(prefix="/consolidations/versions") +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +def _export_filename(group_name: str, year: int, quarter: str) -> str: + return f"Корректировки_{group_name}_{year}_{quarter}.xlsx" + + +@router.get( + "/{version_id}/corrections", + response_model=CorrectionTableOut, + dependencies=VIEW, + summary="Агрегатная таблица корректировок", +) +async def table( + version_id: uuid.UUID, + service: CorrectionServiceDep, + statement_type: StatementType = StatementType.balance, +) -> CorrectionTableOut: + return await service.table(version_id, statement_type) + + +@router.get( + "/{version_id}/corrections/export", + response_class=Response, + dependencies=VIEW, + summary="Таблица корректировок в XLSX", +) +async def export( + version_id: uuid.UUID, + service: CorrectionServiceDep, + consolidation_service: ConsolidationServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + statement_type: StatementType = StatementType.balance, +) -> Response: + content = await service.export(version_id, statement_type, audit) + group_name, year, quarter = await consolidation_service.version_title(version_id) + filename = _export_filename(group_name, year, quarter) + return Response( + content, + media_type=XLSX_MEDIA_TYPE, + headers={"Content-Disposition": content_disposition(filename, ascii_fallback="corrections.xlsx")}, + ) + + +@router.put( + "/{version_id}/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}", + response_model=CorrectionAggregateOut, + dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + summary="Полная замена корректировки агрегата", +) +async def save_correction( # noqa: PLR0913, PLR0917 - ключ агрегата, тело и зависимости FastAPI + version_id: uuid.UUID, + rule_id: uuid.UUID, + company_1_id: uuid.UUID, + company_2_id: uuid.UUID, + data: CorrectionIn, + service: CorrectionServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + statement_type: StatementType = StatementType.balance, +) -> CorrectionAggregateOut: + return await service.save(version_id, rule_id, company_1_id, company_2_id, statement_type, data, audit) diff --git a/api/src/domain/corrections.py b/api/src/domain/corrections.py new file mode 100644 index 0000000..eced114 --- /dev/null +++ b/api/src/domain/corrections.py @@ -0,0 +1,41 @@ +"""Контроль агрегатов нового API корректировок: рубли, допуск не более 5 %. + +Старый calculate_rule_totals (§5 до агрегатной таблицы) не меняется. +Здесь нет расчёта итогового КФО и решения о допустимости APPLIED. +""" + +from dataclasses import dataclass +from decimal import Decimal +from typing import Literal + +ZERO = Decimal("0") +THRESHOLD_PERCENT = Decimal("5") +CorrectionStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] + + +@dataclass(frozen=True) +class CorrectionTotals: + amount_1: Decimal + amount_2: Decimal + deviation_amount: Decimal + deviation_percent: Decimal | None + eliminated_amount: Decimal + exceeds_threshold: bool + status: CorrectionStatus + + +def calculate_totals(amount_1: Decimal, amount_2: Decimal, *, manual: bool = False) -> CorrectionTotals: + """Сравнить исходные суммы без округления до экранных знаков.""" + deviation = abs(amount_1 - amount_2) + base = min(amount_1, amount_2) + percent = deviation / base * Decimal("100") if base > ZERO else None + exceeds = percent > THRESHOLD_PERCENT if percent is not None else deviation > ZERO + eliminated = base if base > ZERO and not manual and not exceeds else ZERO + status: CorrectionStatus + if manual: + status = "MANUAL" + elif ZERO in (amount_1, amount_2): + status = "ONE_SIDED" + else: + status = "AUTO" if eliminated > ZERO else "DISCREPANCY" + return CorrectionTotals(amount_1, amount_2, deviation, percent, eliminated, exceeds, status) diff --git a/api/src/repositories/corrections.py b/api/src/repositories/corrections.py new file mode 100644 index 0000000..ff9c322 --- /dev/null +++ b/api/src/repositories/corrections.py @@ -0,0 +1,91 @@ +"""Хранение решений ВГО; транзакция и проверка видимости принадлежат сервису.""" + +import uuid +from collections.abc import Collection + +from sqlalchemy import Boolean, Column, DateTime, MetaData, String, Table, delete, select +from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.base import SCHEMA +from src.models.consolidation import ConsolidationDocument +from src.models.enums import CorrectionStatus, StatementType +from src.models.files import Document +from src.models.kfo import Correction +from src.models.statements import FinancialStatementLine, FinancialStatementMeta + +vgo_assignment = Table( + "vgo_assignment", + MetaData(schema=SCHEMA), + Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + Column("consolidation_version_id", postgresql.UUID(as_uuid=True), nullable=False), + Column( + "statement_type", + postgresql.ENUM("balance", "income", name="statement_type", schema=SCHEMA, create_type=False), + nullable=False, + ), + Column("osv_entry_id", postgresql.UUID(as_uuid=True), nullable=False), + Column("rule_id", postgresql.UUID(as_uuid=True)), + Column("action", String, nullable=False), + Column("is_exclude", Boolean, nullable=False), + Column("created_by", postgresql.UUID(as_uuid=True)), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("updated_by", postgresql.UUID(as_uuid=True)), + Column("updated_at", DateTime(timezone=True)), +) + + +async def active_corrections( + session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType +) -> list[Correction]: + return list( + ( + await session.scalars( + select(Correction) + .where( + Correction.consolidation_version_id == version_id, + Correction.statement_type == statement_type, + Correction.status.in_((CorrectionStatus.DRAFT, CorrectionStatus.APPLIED)), + ) + .order_by(Correction.id) + ) + ).all() + ) + + +async def bound_form_lines( + session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType, company_ids: Collection[uuid.UUID] +): + """Шапки привязанных форм и их реальные коды; шаблон формы достраивает сервис.""" + return list( + ( + await session.execute( + select(Document, FinancialStatementLine.line_code) + .join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id) + .join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id) + .outerjoin(FinancialStatementLine, FinancialStatementLine.meta_id == FinancialStatementMeta.id) + .where( + ConsolidationDocument.consolidation_version_id == version_id, + FinancialStatementMeta.statement_type == statement_type, + Document.legal_entity_id.in_(company_ids), + ) + ) + ).all() + ) + + +async def replace_correction( + session: AsyncSession, + old_rows: Collection[Correction], + roots: Collection[Correction], + children: Collection[Correction], +) -> None: + """Сохраняет основные id, полностью заменяет детей; версия уже заблокирована сервисом.""" + old_roots = {row.id for row in old_rows if row.parent_correction_id is None} + if old_roots: + await session.execute(delete(Correction).where(Correction.parent_correction_id.in_(old_roots))) + await session.execute(delete(Correction).where(Correction.id.in_(old_roots - {r.id for r in roots}))) + session.add_all(roots) + await session.flush() # self-FK: корни должны существовать до вставки детей + session.add_all(children) + await session.flush() diff --git a/api/src/schemas/corrections.py b/api/src/schemas/corrections.py new file mode 100644 index 0000000..fa20c32 --- /dev/null +++ b/api/src/schemas/corrections.py @@ -0,0 +1,123 @@ +"""Ответы первого этапа таблицы корректировок; деньги в рублях, JSON — строки.""" + +import datetime +import uuid +from decimal import ROUND_HALF_UP, Decimal +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, PlainSerializer + +from src.models.enums import StatementType +from src.schemas.consolidations import RefOut + +Money = Annotated[ + Decimal, + PlainSerializer( + lambda value: format(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), ".2f"), + return_type=str, + when_used="json", + ), +] +Side = Literal["D", "K"] + + +class CorrectionRuleOut(BaseModel): + id: uuid.UUID + rule_no: int + article_1_line: str + article_2_line: str + side_1: Side + measure: Literal["closing", "turnover"] + company_1_accounts: list[str] + company_2_accounts: list[str] + company_1_excluded: list[str] + company_2_excluded: list[str] + + +class CorrectionEntryOut(BaseModel): + entry_ids: list[uuid.UUID] + legal_entity_id: uuid.UUID + counterparty_id: uuid.UUID + account_code: str + side: Side + amount: Money + + +class AggregateSideOut(BaseModel): + article_line: str + accounts: list[str] + amount: Money + entries: list[CorrectionEntryOut] + + +class CorrectionLineOut(BaseModel): + line_code: str + amount: Money + + +class CorrectionBalanceOut(BaseModel): + debit_total: Money + credit_total: Money + difference: Money + difference_percent: Money | None + remainder_1: Money + remainder_2: Money + + +class CorrectionOut(BaseModel): + status: Literal["DRAFT", "APPLIED"] + version_no: int + debit: list[CorrectionLineOut] + credit: list[CorrectionLineOut] + comment: str + reason_code: str | None + balance: CorrectionBalanceOut + updated_by: RefOut | None + updated_at: datetime.datetime + + +class CorrectionAggregateOut(BaseModel): + rule_id: uuid.UUID + rule_no: int + company_1: RefOut + company_2: RefOut + side_1: AggregateSideOut + side_2: AggregateSideOut + deviation_amount: Money + deviation_percent: Money | None + exceeds_threshold: bool + status: Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] + eliminated_amount: Money + correction: CorrectionOut | None + + +class CorrectionTotalsOut(BaseModel): + amount_1: Money + amount_2: Money + deviation_amount: Money + correction_debit: Money + correction_credit: Money + + +class CorrectionTableOut(BaseModel): + version_id: uuid.UUID + statement_type: StatementType + rules_version_no: int + editable: bool + read_only_reason: Literal["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"] | None + rules: list[CorrectionRuleOut] + aggregates: list[CorrectionAggregateOut] + totals: CorrectionTotalsOut + + +class CorrectionLineIn(BaseModel): + line_code: Annotated[str, Field(min_length=1)] + amount: Annotated[str, Field(pattern=r"^\d{1,16}(\.\d{1,2})?$")] + + +class CorrectionIn(BaseModel): + debit: list[CorrectionLineIn] + credit: list[CorrectionLineIn] + comment: Annotated[str, Field(max_length=1000)] + reason_code: str | None = None + expected_version_no: Annotated[int, Field(ge=1)] | None diff --git a/api/src/services/correction_source.py b/api/src/services/correction_source.py new file mode 100644 index 0000000..30df460 --- /dev/null +++ b/api/src/services/correction_source.py @@ -0,0 +1,253 @@ +"""Граница с задачей распределения ОСВ: чтение готовых ``vgo_assignment``.""" + +import uuid +from collections import defaultdict +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Literal, Protocol + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from src.common.exceptions import ConflictError +from src.domain.corrections import calculate_totals +from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import LegalEntity +from src.models.enums import StatementType +from src.models.osv import OsvEntry +from src.repositories.corrections import vgo_assignment +from src.schemas.consolidations import RefOut +from src.schemas.corrections import AggregateSideOut, CorrectionAggregateOut, CorrectionEntryOut, CorrectionRuleOut + +ZERO = Decimal("0") +Side = Literal["D", "K"] +COMPANY_NUMBERS = (1, 2) + + +@dataclass(frozen=True) +class CorrectionSourceTable: + rules: list[CorrectionRuleOut] = field(default_factory=list) + aggregates: list[CorrectionAggregateOut] = field(default_factory=list) + + +class CorrectionAggregateSource(Protocol): + async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: ... + + +@dataclass +class _RuleSpec: + rule: EliminationRule + included: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()}) + excluded: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()}) + included_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) + excluded_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) + + +@dataclass +class _EntryGroup: + ids: list[uuid.UUID] = field(default_factory=list) + amount: Decimal = ZERO + + +@dataclass +class _AggregateGroup: + rule: EliminationRule + company_1: LegalEntity + company_2: LegalEntity + entries: dict[int, dict[tuple[uuid.UUID, uuid.UUID, str, Side], _EntryGroup]] = field( + default_factory=lambda: {1: {}, 2: {}} + ) + + +def _account_tree(accounts: list[ChartOfAccount]) -> dict[uuid.UUID, set[uuid.UUID]]: + children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) + for account in accounts: + if account.parent_id is not None: + children[account.parent_id].append(account.id) + descendants: dict[uuid.UUID, set[uuid.UUID]] = {} + for account in accounts: + result: set[uuid.UUID] = set() + pending = [account.id] + while pending: + current = pending.pop() + if current not in result: + result.add(current) + pending.extend(children.get(current, ())) + descendants[account.id] = result + return descendants + + +def _amount(entry: OsvEntry, measure: str, side: Side) -> Decimal: + if measure == "closing": + value = entry.closing_debit if side == "D" else entry.closing_credit + else: + value = entry.turnover_debit if side == "D" else entry.turnover_credit + return value or ZERO + + +def _direction(rule: EliminationRule, company_no: int) -> Side: + first = rule.side_1 + if first not in ("D", "K"): + raise ConflictError("CORRECTION_SOURCE_INVALID", "В правиле задана неизвестная сторона") + if company_no == 1: + return "D" if first == "D" else "K" + return "K" if first == "D" else "D" + + +def _company_no(spec: _RuleSpec, entry: OsvEntry) -> int: + if entry.account_id is None: + raise ConflictError("CORRECTION_SOURCE_INVALID", "У назначенной строки ОСВ отсутствует счёт") + matched = [number for number in COMPANY_NUMBERS if entry.account_id in spec.included[number]] + if len(matched) == 1: + return matched[0] + if len(matched) == len(COMPANY_NUMBERS): + nonzero = [ + number for number in matched if _amount(entry, spec.rule.measure, _direction(spec.rule, number)) != ZERO + ] + if len(nonzero) == 1: + return nonzero[0] + raise ConflictError("CORRECTION_SOURCE_INVALID", "Невозможно определить сторону назначенной строки ОСВ в правиле") + + +class VgoAssignmentCorrectionSource: + """Строит таблицу из готовых назначений, не создавая и не меняя их.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def _rules(self, statement_type: StatementType, version_no: int) -> dict[uuid.UUID, _RuleSpec]: + accounts = list((await self._session.scalars(select(ChartOfAccount))).all()) + by_id = {account.id: account for account in accounts} + descendants = _account_tree(accounts) + rows = ( + await self._session.execute( + select(EliminationRule, EliminationRuleAccount) + .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) + .where( + EliminationRule.statement_type == statement_type, + EliminationRule.version_no == version_no, + ) + .order_by(EliminationRule.rule_no, EliminationRuleAccount.company_no) + ) + ).all() + specs: dict[uuid.UUID, _RuleSpec] = {} + for rule, account in rows: + spec = specs.setdefault(rule.id, _RuleSpec(rule)) + if account is None or account.company_no not in COMPANY_NUMBERS: + continue + target = spec.excluded if account.is_excluded else spec.included + target[account.company_no].update(descendants.get(account.account_id, {account.account_id})) + code = by_id.get(account.account_id) + if code is not None: + codes = spec.excluded_codes if account.is_excluded else spec.included_codes + codes[account.company_no].append(code.code) + for spec in specs.values(): + for number in COMPANY_NUMBERS: + spec.included[number].difference_update(spec.excluded[number]) + return specs + + async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: + version = await self._session.get(ConsolidationVersion, version_id) + if version is None: + return CorrectionSourceTable() + specs = await self._rules(statement_type, version.elimination_rules_version) + owner = aliased(LegalEntity) + counterparty = aliased(LegalEntity) + rows = ( + await self._session.execute( + select(OsvEntry, EliminationRule, owner, counterparty) + .select_from(vgo_assignment) + .join(OsvEntry, OsvEntry.id == vgo_assignment.c.osv_entry_id) + .join(EliminationRule, EliminationRule.id == vgo_assignment.c.rule_id) + .join(owner, owner.id == OsvEntry.owner_legal_entity_id) + .join(counterparty, counterparty.id == OsvEntry.counterparty_id) + .join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id) + .where( + vgo_assignment.c.consolidation_version_id == version_id, + vgo_assignment.c.statement_type == statement_type.value, + vgo_assignment.c.action == "ASSIGN", + vgo_assignment.c.is_exclude.is_(False), + ConsolidationDocument.consolidation_version_id == version_id, + ) + .order_by(EliminationRule.rule_no, owner.name, counterparty.name, OsvEntry.id) + ) + ).all() + groups: dict[tuple[uuid.UUID, uuid.UUID, uuid.UUID], _AggregateGroup] = {} + used_rules: set[uuid.UUID] = set() + for entry, rule, owner_entity, counterparty_entity in rows: + spec = specs.get(rule.id) + if spec is None: + raise ConflictError("CORRECTION_SOURCE_INVALID", "Назначение ссылается на неизвестное правило") + company_no = _company_no(spec, entry) + direction = _direction(rule, company_no) + first, second = ( + (owner_entity, counterparty_entity) if company_no == 1 else (counterparty_entity, owner_entity) + ) + key = (rule.id, first.id, second.id) + group = groups.setdefault(key, _AggregateGroup(rule, first, second)) + entry_key = (owner_entity.id, counterparty_entity.id, entry.account_code or "", direction) + item = group.entries[company_no].setdefault(entry_key, _EntryGroup()) + item.ids.append(entry.id) + item.amount += _amount(entry, rule.measure, direction) + used_rules.add(rule.id) + aggregates = [self._aggregate(group) for group in groups.values()] + rules = [self._rule_out(spec) for rule_id, spec in specs.items() if rule_id in used_rules] + return CorrectionSourceTable(rules=rules, aggregates=aggregates) + + @staticmethod + def _rule_out(spec: _RuleSpec) -> CorrectionRuleOut: + rule = spec.rule + return CorrectionRuleOut( + id=rule.id, + rule_no=rule.rule_no, + article_1_line=rule.article_1_line, + article_2_line=rule.article_2_line, + side_1=_direction(rule, 1), + measure=rule.measure, + company_1_accounts=sorted(set(spec.included_codes[1])), + company_2_accounts=sorted(set(spec.included_codes[2])), + company_1_excluded=sorted(set(spec.excluded_codes[1])), + company_2_excluded=sorted(set(spec.excluded_codes[2])), + ) + + @staticmethod + def _aggregate(group: _AggregateGroup) -> CorrectionAggregateOut: + rule = group.rule + + def side(number: int) -> AggregateSideOut: + entries = [ + CorrectionEntryOut( + entry_ids=sorted(item.ids), + legal_entity_id=owner_id, + counterparty_id=counterparty_id, + account_code=account_code, + side=direction, + amount=item.amount, + ) + for (owner_id, counterparty_id, account_code, direction), item in group.entries[number].items() + ] + return AggregateSideOut( + article_line=rule.article_1_line if number == 1 else rule.article_2_line, + accounts=sorted({entry.account_code for entry in entries}), + amount=sum((entry.amount for entry in entries), ZERO), + entries=entries, + ) + + first, second = side(1), side(2) + totals = calculate_totals(first.amount, second.amount, manual=rule.elimination_mode == "manual") + return CorrectionAggregateOut( + rule_id=rule.id, + rule_no=rule.rule_no, + company_1=RefOut(id=group.company_1.id, name=group.company_1.name), + company_2=RefOut(id=group.company_2.id, name=group.company_2.name), + side_1=first, + side_2=second, + deviation_amount=totals.deviation_amount, + deviation_percent=totals.deviation_percent, + exceeds_threshold=totals.exceeds_threshold, + status=totals.status, + eliminated_amount=totals.eliminated_amount, + correction=None, + ) diff --git a/api/src/services/corrections.py b/api/src/services/corrections.py new file mode 100644 index 0000000..0a2361c --- /dev/null +++ b/api/src/services/corrections.py @@ -0,0 +1,242 @@ +"""Сборка агрегатной таблицы первого этапа; исходные ОСВ не изменяются.""" + +import uuid +from collections import defaultdict +from decimal import Decimal +from typing import Literal + +from fastapi.concurrency import run_in_threadpool +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.api.dependencies.auth import Principal +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.policy import VisibilityScope, ensure_version_editable +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_mapping import edition_for +from src.models.access import AppUser +from src.models.enums import AuditAction, AuditSection, StatementType +from src.repositories import consolidations as version_repo +from src.repositories import corrections as repo +from src.schemas.consolidations import RefOut +from src.schemas.corrections import ( + CorrectionAggregateOut, + CorrectionIn, + CorrectionTableOut, + CorrectionTotalsOut, +) +from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version +from src.services.correction_source import CorrectionAggregateSource +from src.services.corrections_export import build_workbook +from src.services.corrections_view import attach_corrections +from src.services.corrections_write import correction_rows + +ZERO = Decimal("0") + + +class CorrectionService: + def __init__( + self, + session: AsyncSession, + scope: VisibilityScope, + principal: Principal, + source: CorrectionAggregateSource, + ) -> None: + self._session, self._scope, self._principal = session, scope, principal + self._source = source + + async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionTableOut: + found = await version_repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + version, _ = found + reason: Literal["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"] | None = None + if "CONSOLIDATION_EDIT" not in self._principal.permissions: + reason = "VERSION_NOT_EDITABLE" + else: + try: + ensure_version_editable( + version, await version_repo.active_lock(self._session, version_id), self._principal.user.id + ) + except ConflictError as error: + if error.code not in ("VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"): + raise + if error.code == "LOCK_REQUIRED": + reason = "LOCK_REQUIRED" + elif error.code == "LOCKED_BY_OTHER": + reason = "LOCKED_BY_OTHER" + else: + reason = "VERSION_NOT_EDITABLE" + source = await self._source.table(version_id, statement_type) + rules = sorted(source.rules, key=lambda item: (item.rule_no, item.id)) + aggregates = sorted( + source.aggregates, + key=lambda item: ( + item.rule_no, + item.company_1.name, + item.company_2.name, + item.company_1.id, + item.company_2.id, + ), + ) + for aggregate in aggregates: + for side in (aggregate.side_1, aggregate.side_2): + side.entries.sort( + key=lambda entry: ( + entry.account_code, + entry.legal_entity_id, + entry.counterparty_id, + entry.side, + entry.entry_ids, + ) + ) + active = await repo.active_corrections(self._session, version_id, statement_type) + user_ids = {row.updated_by for row in active if row.updated_by is not None} + users = ( + list((await self._session.scalars(select(AppUser).where(AppUser.id.in_(user_ids)))).all()) + if user_ids + else [] + ) + attach_corrections(aggregates, active, {user.id: RefOut(id=user.id, name=user.full_name) for user in users}) + return CorrectionTableOut( + version_id=version_id, + statement_type=statement_type, + rules_version_no=version.elimination_rules_version, + editable=reason is None, + read_only_reason=reason, + rules=rules, + aggregates=aggregates, + totals=CorrectionTotalsOut( + amount_1=sum((a.side_1.amount for a in aggregates), ZERO), + amount_2=sum((a.side_2.amount for a in aggregates), ZERO), + deviation_amount=sum((a.deviation_amount for a in aggregates), ZERO), + correction_debit=sum( + ( + a.correction.balance.debit_total + for a in aggregates + if a.correction is not None and a.correction.status == "APPLIED" + ), + ZERO, + ), + correction_credit=sum( + ( + a.correction.balance.credit_total + for a in aggregates + if a.correction is not None and a.correction.status == "APPLIED" + ), + ZERO, + ), + ), + ) + + async def save( # noqa: PLR0913, PLR0917 - естественный ключ агрегата и контекст запроса + self, + version_id: uuid.UUID, + rule_id: uuid.UUID, + company_1_id: uuid.UUID, + company_2_id: uuid.UUID, + statement_type: StatementType, + data: CorrectionIn, + audit: RequestAudit, + ) -> CorrectionAggregateOut: + await lock_editable_version(self._session, self._scope, version_id, self._principal.user.id) + table = await self.table(version_id, statement_type) + aggregate = next( + ( + a + for a in table.aggregates + if (a.rule_id, a.company_1.id, a.company_2.id) == (rule_id, company_1_id, company_2_id) + ), + None, + ) + if aggregate is None: + raise NotFoundError("Агрегат не найден") + current = aggregate.correction + if data.expected_version_no != (current.version_no if current else None): + raise ConflictError("CORRECTION_VERSION_CONFLICT", "Корректировка уже изменена; перечитайте таблицу") + if not data.comment.strip(): + raise DomainValidationError("COMMENT_REQUIRED", "Укажите комментарий") + if not data.debit and not data.credit: + raise DomainValidationError("EMPTY_CORRECTION", "Укажите хотя бы одну статью корректировки") + allowed: dict[uuid.UUID, set[str]] = defaultdict(set) + catalog = load_line_catalog() + for document, code in await repo.bound_form_lines( + self._session, version_id, statement_type, (company_1_id, company_2_id) + ): + if code: + allowed[document.legal_entity_id].add(code) + allowed[document.legal_entity_id].update( + line.code + for line in catalog.template_lines(edition_for(document.reporting_date), document.document_type.value) + ) + for company, lines in ((company_1_id, data.debit), (company_2_id, data.credit)): + for line in lines: + if line.line_code not in allowed[company]: + raise DomainValidationError( + "INVALID_LINE_CODE", "Статья отсутствует в форме компании", line_code=line.line_code + ) + old = [ + row + for row in await repo.active_corrections(self._session, version_id, statement_type) + if row.rule_id == rule_id + and ( + (row.legal_entity_id, row.counterparty_id) + if row.debit_line_code is not None + else (row.counterparty_id, row.legal_entity_id) + ) + == (company_1_id, company_2_id) + ] + # Снимок DTO до изменения ORM нужен для точного before аудита. + aggregate_key = dict( + rule_id=str(rule_id), + company_1_id=str(company_1_id), + company_2_id=str(company_2_id), + statement_type=statement_type.value, + ) + before = {**current.model_dump(mode="json"), **aggregate_key} if current else None + roots, children = correction_rows( + aggregate, data, old, version_id=version_id, statement_type=statement_type, user_id=self._principal.user.id + ) + await repo.replace_correction(self._session, old, roots, children) + result = next( + a + for a in (await self.table(version_id, statement_type)).aggregates + if (a.rule_id, a.company_1.id, a.company_2.id) == (rule_id, company_1_id, company_2_id) + ) + await audit.record( + action=AuditAction.CORRECTION_EDIT if current else AuditAction.CORRECTION_CREATE, + section=AuditSection.CONSOLIDATION, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + before_value=before, + after_value={ + **result.correction.model_dump(mode="json"), + **aggregate_key, + "source_amount_1": result.side_1.amount, + "source_amount_2": result.side_2.amount, + "source_deviation_amount": result.deviation_amount, + "source_deviation_percent": result.deviation_percent, + } + if result.correction + else None, + ) + return result + + async def export(self, version_id: uuid.UUID, statement_type: StatementType, audit: RequestAudit) -> bytes: + table = await self.table(version_id, statement_type) + content = await run_in_threadpool(build_workbook, table) + await audit.record( + action=AuditAction.EXPORT, + section=AuditSection.EXPORT, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + after_value={ + "report": "corrections", + "statement_type": statement_type, + "aggregates": len(table.aggregates), + }, + ) + return content diff --git a/api/src/services/corrections_export.py b/api/src/services/corrections_export.py new file mode 100644 index 0000000..588fd83 --- /dev/null +++ b/api/src/services/corrections_export.py @@ -0,0 +1,96 @@ +"""Чистая XLSX-выгрузка DTO таблицы корректировок, суммы в рублях.""" + +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.worksheet.worksheet import Worksheet + +from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut +from src.services.osv_export import _put_money, _put_text + +MAIN_HEADER = ( + "Правило", + "Дебет — ЮЛ", + "Дебет — статья", + "Дебет — счета", + "Дебет — сумма, руб.", + "Кредит — ЮЛ", + "Кредит — статья", + "Кредит — счета", + "Кредит — сумма, руб.", + "Отклонение, руб.", + "Отклонение, %", + "Корректировка Дт, руб.", + "Корректировка Кт, руб.", + "Статьи корректировки Дт", + "Статьи корректировки Кт", + "Комментарий", +) + + +def _aggregate(sheet: Worksheet, row: int, item: CorrectionAggregateOut) -> None: + sheet.cell(row, 1, item.rule_no) + for column, text in ( + (2, item.company_1.name), + (3, item.side_1.article_line), + (4, ", ".join(item.side_1.accounts)), + (6, item.company_2.name), + (7, item.side_2.article_line), + (8, ", ".join(item.side_2.accounts)), + ): + _put_text(sheet, row, column, text) + for column, amount in ( + (5, item.side_1.amount), + (9, item.side_2.amount), + (10, item.deviation_amount), + (11, item.deviation_percent), + ): + _put_money(sheet, row, column, amount) + correction = item.correction + if correction is not None and correction.status == "APPLIED": + _put_money(sheet, row, 12, correction.balance.debit_total) + _put_money(sheet, row, 13, correction.balance.credit_total) + _put_text(sheet, row, 14, "; ".join(f"{line.line_code}: {line.amount:.2f}" for line in correction.debit)) + _put_text(sheet, row, 15, "; ".join(f"{line.line_code}: {line.amount:.2f}" for line in correction.credit)) + _put_text(sheet, row, 16, correction.comment) + + +def _entry(sheet: Worksheet, row: int, item: CorrectionAggregateOut, entry: CorrectionEntryOut) -> None: + """Строка ОСВ под агрегатом на тех же колонках дебета/кредита.""" + debit = entry.side == "D" + owner = item.company_1.name if entry.legal_entity_id == item.company_1.id else item.company_2.name + _put_text(sheet, row, 2 if debit else 6, owner) + _put_text(sheet, row, 4 if debit else 8, entry.account_code) + _put_money(sheet, row, 5 if debit else 9, entry.amount) + sheet.row_dimensions[row].outlineLevel = 1 + + +def build_workbook(table: CorrectionTableOut) -> bytes: + """Состав и суммы уже рассчитаны API: экспорт их не пересчитывает.""" + workbook = Workbook() + sheet = workbook.active + assert sheet is not None + sheet.title = "Корректировки" + sheet.append(MAIN_HEADER) + row = 2 + for item in table.aggregates: + _aggregate(sheet, row, item) + row += 1 + for side in (item.side_1, item.side_2): + for entry in side.entries: + _entry(sheet, row, item, entry) + row += 1 + _put_text(sheet, row, 2, "Итого по агрегатам") + for column, amount in ( + (5, table.totals.amount_1), + (9, table.totals.amount_2), + (10, table.totals.deviation_amount), + (12, table.totals.correction_debit), + (13, table.totals.correction_credit), + ): + _put_money(sheet, row, column, amount) + sheet.freeze_panes = "A2" + buffer = BytesIO() + workbook.save(buffer) + workbook.close() + return buffer.getvalue() diff --git a/api/src/services/corrections_view.py b/api/src/services/corrections_view.py new file mode 100644 index 0000000..d3eb719 --- /dev/null +++ b/api/src/services/corrections_view.py @@ -0,0 +1,84 @@ +"""Показ сохранённых APPLIED/DRAFT-корректировок по статьям.""" + +import uuid +from collections import defaultdict +from collections.abc import Sequence +from decimal import Decimal +from typing import Literal, cast + +from src.common.exceptions import ConflictError +from src.models.enums import CorrectionStatus +from src.models.kfo import Correction +from src.schemas.consolidations import RefOut +from src.schemas.corrections import CorrectionAggregateOut, CorrectionBalanceOut, CorrectionLineOut, CorrectionOut + +ZERO = Decimal("0") + + +def attach_corrections( + aggregates: Sequence[CorrectionAggregateOut], rows: Sequence[Correction], users: dict[uuid.UUID, RefOut] +) -> None: + """Две основные записи и дочерние статьи — один набор решения по агрегату.""" + grouped: dict[tuple[uuid.UUID, uuid.UUID, uuid.UUID], list[Correction]] = defaultdict(list) + for row in rows: + if row.rule_id is None or row.legal_entity_id is None or row.counterparty_id is None: + raise ConflictError("CORRECTION_REVIEW_REQUIRED", "Корректировка не привязана к агрегату") + first, second = row.legal_entity_id, row.counterparty_id + if row.credit_line_code is not None: + first, second = second, first + grouped[row.rule_id, first, second].append(row) + by_key = {(a.rule_id, a.company_1.id, a.company_2.id): a for a in aggregates} + for key, corrections in grouped.items(): + aggregate = by_key.get(key) + if aggregate is None: + continue + ordered = sorted(corrections, key=lambda c: (c.parent_correction_id is not None, c.id)) + roots = [c for c in ordered if c.parent_correction_id is None] + root_ids = {c.id for c in roots} + if not roots or any( + c.parent_correction_id not in root_ids for c in ordered if c.parent_correction_id is not None + ): + raise ConflictError( + "CORRECTION_REVIEW_REQUIRED", "Структура дочерних статей корректировки требует проверки" + ) + if len({c.version_no for c in corrections}) != 1 or len({c.comment for c in roots}) != 1: + raise ConflictError("CORRECTION_REVIEW_REQUIRED", "Редакции сторон корректировки не согласованы") + debit = [ + CorrectionLineOut(line_code=c.debit_line_code, amount=c.debit_adjustment or ZERO) + for c in ordered + if c.debit_line_code is not None + ] + credit = [ + CorrectionLineOut(line_code=c.credit_line_code, amount=c.credit_adjustment or ZERO) + for c in ordered + if c.credit_line_code is not None + ] + total_d = sum((line.amount for line in debit), ZERO) + total_k = sum((line.amount for line in credit), ZERO) + difference = abs(total_d - total_k) + base = min(total_d, total_k) + root = roots[0] + updated_at = root.updated_at or root.created_at + if updated_at is None: + raise ConflictError("CORRECTION_REVIEW_REQUIRED", "У корректировки отсутствует время сохранения") + statuses = {c.status for c in corrections} + if len(statuses) != 1 or statuses - {CorrectionStatus.DRAFT, CorrectionStatus.APPLIED}: + raise ConflictError("CORRECTION_REVIEW_REQUIRED", "Статусы строк корректировки не согласованы") + aggregate.correction = CorrectionOut( + status=cast(Literal["DRAFT", "APPLIED"], next(iter(statuses)).value), + version_no=root.version_no, + debit=debit, + credit=credit, + comment=root.comment, + reason_code=root.reason_code, + updated_by=users.get(root.updated_by) if root.updated_by else None, + updated_at=updated_at, + balance=CorrectionBalanceOut( + debit_total=total_d, + credit_total=total_k, + difference=difference, + difference_percent=difference / base * 100 if base > ZERO else None, + remainder_1=aggregate.side_1.amount - total_d, + remainder_2=aggregate.side_2.amount - total_k, + ), + ) diff --git a/api/src/services/corrections_write.py b/api/src/services/corrections_write.py new file mode 100644 index 0000000..79bf4af --- /dev/null +++ b/api/src/services/corrections_write.py @@ -0,0 +1,66 @@ +"""План полной замены применённой корректировки.""" + +import datetime +import uuid +from collections.abc import Sequence +from decimal import ROUND_HALF_UP, Decimal + +from src.models.enums import CorrectionStatus, StatementType +from src.models.kfo import Correction +from src.schemas.corrections import CorrectionAggregateOut, CorrectionIn + + +def _optional_numeric(value: Decimal | None, precision: int, scale: int) -> Decimal | None: + """Необязательные справочные поля старой модели не ограничивают сохранение. + + Полные значения остаются в вычисляемом DTO и аудите. Не подменяем выход за + диапазон обрезкой. + """ + limit = Decimal(10) ** (precision - scale) + if value is None or abs(value) >= limit: + return None + rounded = value.quantize(Decimal(10) ** -scale, rounding=ROUND_HALF_UP) + return rounded if abs(rounded) < limit else None + + +def correction_rows( # noqa: PLR0913 - данные решения и автор сохранения + aggregate: CorrectionAggregateOut, + data: CorrectionIn, + old: Sequence[Correction], + *, + version_id: uuid.UUID, + statement_type: StatementType, + user_id: uuid.UUID, +) -> tuple[list[Correction], list[Correction]]: + now = datetime.datetime.now(datetime.UTC) + revision = (aggregate.correction.version_no if aggregate.correction else 0) + 1 + roots: list[Correction] = [] + children: list[Correction] = [] + for debit, lines in ((True, data.debit), (False, data.credit)): + if not lines: + continue + owner = aggregate.company_1.id if debit else aggregate.company_2.id + other = aggregate.company_2.id if debit else aggregate.company_1.id + previous = next((r for r in old if r.parent_correction_id is None and r.legal_entity_id == owner), None) + root_id = previous.id if previous else uuid.uuid4() + for index, line in enumerate(lines): + row = previous if index == 0 and previous else Correction(id=root_id if index == 0 else uuid.uuid4()) + row.consolidation_version_id = version_id + row.statement_type = statement_type + row.rule_id = aggregate.rule_id + row.parent_correction_id = None if index == 0 else root_id + row.legal_entity_id, row.counterparty_id = owner, other + row.debit_line_code = line.line_code if debit else None + row.credit_line_code = None if debit else line.line_code + row.debit_adjustment = Decimal(line.amount) if debit else None + row.credit_adjustment = None if debit else Decimal(line.amount) + row.debit_account_id = row.credit_account_id = None + row.comment, row.reason_code = data.comment.strip(), data.reason_code + row.deviation_amount = _optional_numeric(aggregate.deviation_amount, 18, 2) + row.deviation_percent = _optional_numeric(aggregate.deviation_percent, 9, 4) + row.status, row.version_no = CorrectionStatus.APPLIED, revision + if row.created_at is None: + row.created_by, row.created_at = user_id, now + row.updated_by, row.updated_at = user_id, now + (roots if index == 0 else children).append(row) + return roots, children diff --git a/api/tests/test_api_corrections.py b/api/tests/test_api_corrections.py new file mode 100644 index 0000000..48be15c --- /dev/null +++ b/api/tests/test_api_corrections.py @@ -0,0 +1,308 @@ +"""API таблицы и экспорта: реальные суммы ОСВ, права, видимость и отсутствие записи на GET.""" + +import datetime +import uuid +from decimal import Decimal +from io import BytesIO +from pathlib import Path + +import pytest +from fastapi import FastAPI +from openpyxl import load_workbook +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.consolidations import get_correction_source +from src.api.endpoints.consolidations.corrections import _export_filename +from src.models.accounting import ChartOfAccount, EliminationRule +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument +from src.models.enums import AuditAction, ConsolidationStatus, CorrectionStatus, PeriodQuarter, StatementType +from src.models.kfo import Correction +from src.models.osv import OsvEntry +from src.repositories.corrections import vgo_assignment +from src.schemas.corrections import CorrectionAggregateOut +from src.services.correction_source import CorrectionSourceTable +from tests.conftest import AuthClientFactory +from tests.factories import make_confirmed_osv +from tests.test_api_consolidated_osv import FOREIGN, KA_RF, _world +from tests.test_intercompany_matrix_golden import _source + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] +HTTP_OK, HTTP_NOT_FOUND, HTTP_FORBIDDEN = 200, 404, 403 +TRADE_RULE_NO = 6 + + +class MutableCorrectionSource: + def __init__(self) -> None: + self.tables: dict[StatementType, CorrectionSourceTable] = {} + + async def table(self, version_id, statement_type): + del version_id + table = self.tables.get(statement_type, CorrectionSourceTable()) + return CorrectionSourceTable( + rules=[rule.model_copy(deep=True) for rule in table.rules], + aggregates=[aggregate.model_copy(deep=True) for aggregate in table.aggregates], + ) + + +@pytest.fixture +def correction_source(app: FastAPI): + source = MutableCorrectionSource() + app.dependency_overrides[get_correction_source] = lambda: source + yield source + app.dependency_overrides.pop(get_correction_source, None) + + +async def configure_trade_source( + source: MutableCorrectionSource, session: AsyncSession, world, fixtures_dir: Path +) -> None: + rule = ( + await session.scalars( + select(EliminationRule).where( + EliminationRule.rule_no == TRADE_RULE_NO, + EliminationRule.statement_type == StatementType.balance, + EliminationRule.version_no == 1, + ) + ) + ).one() + amount = Decimal(_source(fixtures_dir, "schastl", "62", 21)["closing_debit"]) + + def entry(owner, other, account, side, entry_id): + return dict( + entry_ids=[uuid.UUID(int=entry_id)], + legal_entity_id=owner.id, + counterparty_id=other.id, + account_code=account, + side=side, + amount=amount, + ) + + aggregate = CorrectionAggregateOut.model_validate( + dict( + rule_id=rule.id, + rule_no=rule.rule_no, + company_1=dict(id=world.a.id, name=world.a.name), + company_2=dict(id=world.b.id, name=world.b.name), + side_1=dict( + article_line="1230", + accounts=["62.01"], + amount=amount, + entries=[entry(world.a, world.b, "62.01", "D", 1)], + ), + side_2=dict( + article_line="1520", + accounts=["60.01"], + amount=amount, + entries=[entry(world.b, world.a, "60.01", "K", 2)], + ), + deviation_amount=0, + deviation_percent=0, + exceeds_threshold=False, + status="AUTO", + eliminated_amount=amount, + correction=None, + ) + ) + source.tables[StatementType.balance] = CorrectionSourceTable(aggregates=[aggregate]) + + +async def real_trade(session: AsyncSession, fixtures_dir: Path): + world = await _world(session) + await session.execute( + delete(ConsolidationDocument).where(ConsolidationDocument.consolidation_version_id == world.version.id) + ) + for owner, other, folder, root, number, side in ( + (world.a, world.b, "schastl", "62", 21, "closing_debit"), + (world.b, world.a, "rb", "60", 409, "closing_credit"), + ): + row = _source(fixtures_dir, folder, root, number) + account = ( + await session.scalars(select(ChartOfAccount).where(ChartOfAccount.code == row["account_code_raw"])) + ).one() + document = await make_confirmed_osv( + session, + entity=owner, + account=account.code, + year=2024, + quarter=PeriodQuarter.Q3, + is_cumulative=True, + rows=[ + dict( + name=other.name, + inn=other.inn, + account_id=account.id, + counterparty_id=other.id, + **{side: Decimal(row[side])}, + ) + ], + ) + session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) + await session.flush() + return world + + +async def test_read_and_export_real_trade( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +) -> None: + world = await real_trade(seeded, fixtures_dir) + await configure_trade_source(correction_source, seeded, world, fixtures_dir) + client = await auth_client(KA_RF) + url = f"/consolidations/versions/{world.version.id}/corrections" + response = await client.get(url) + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert len(body["aggregates"]) == 1 + aggregate = body["aggregates"][0] + assert aggregate["rule_no"] == 6 # noqa: PLR2004 - правило реальной торговой пары + assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46" + assert aggregate["eliminated_amount"] == "106311116.46" + assert aggregate["correction"] is None + assert body["read_only_reason"] == "LOCK_REQUIRED" + assert await seeded.scalar(select(func.count()).select_from(Correction)) == 0 + assert ( + await seeded.scalar(select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT)) + == 0 + ) + exported = await client.get(url + "/export") + assert exported.status_code == HTTP_OK, exported.text + assert "attachment" in exported.headers["content-disposition"] + assert "spreadsheetml" in exported.headers["content-type"] + workbook = load_workbook(BytesIO(exported.content), data_only=True) + try: + assert Decimal(str(workbook["Корректировки"]["E2"].value)) == Decimal("106311116.46") + finally: + workbook.close() + audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() + assert audit.consolidation_version_id == world.version.id + assert audit.after_value["report"] == "corrections" + income = await client.get(url, params={"statement_type": "income"}) + assert income.status_code == HTTP_OK + assert income.json()["aggregates"] == [] + + +async def test_ready_assignments_build_correction_aggregate( + auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path +) -> None: + world = await real_trade(seeded, fixtures_dir) + rule = ( + await seeded.scalars( + select(EliminationRule).where( + EliminationRule.rule_no == TRADE_RULE_NO, + EliminationRule.statement_type == StatementType.balance, + EliminationRule.version_no == world.version.elimination_rules_version, + ) + ) + ).one() + entries = list( + ( + await seeded.scalars( + select(OsvEntry) + .join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id) + .where(ConsolidationDocument.consolidation_version_id == world.version.id) + ) + ).all() + ) + await seeded.execute( + vgo_assignment.insert(), + [ + dict( + id=uuid.uuid4(), + consolidation_version_id=world.version.id, + statement_type=StatementType.balance.value, + osv_entry_id=entry.id, + rule_id=rule.id, + action="ASSIGN", + is_exclude=False, + ) + for entry in entries + ], + ) + await seeded.flush() + + response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections") + + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert len(body["aggregates"]) == 1 + aggregate = body["aggregates"][0] + assert aggregate["rule_no"] == TRADE_RULE_NO + assert aggregate["side_1"]["amount"] == aggregate["side_2"]["amount"] == "106311116.46" + assert [entry["account_code"] for entry in aggregate["side_1"]["entries"]] == ["62.01"] + assert [entry["account_code"] for entry in aggregate["side_2"]["entries"]] == ["60.01"] + + +async def test_table_read_only_visibility_and_unknown_version( + auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path +) -> None: + world = await real_trade(seeded, fixtures_dir) + world.version.status = ConsolidationStatus.VERIFIED + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/consolidations/versions/{world.version.id}/corrections" + response = await client.get(url) + assert response.status_code == HTTP_OK + assert response.json()["read_only_reason"] == "VERSION_NOT_EDITABLE" + assert response.json()["aggregates"] == [] + missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/corrections") + foreign = await (await auth_client(FOREIGN)).get(url) + assert missing.status_code == foreign.status_code == HTTP_NOT_FOUND + assert missing.json() == foreign.json() + + +async def test_view_permission_is_required_before_visibility(auth_client: AuthClientFactory) -> None: + client = await auth_client("admin@armka.local") + for suffix in ("corrections", "corrections/export"): + response = await client.get(f"/consolidations/versions/{uuid.uuid4()}/{suffix}") + assert response.status_code == HTTP_FORBIDDEN + + +def test_corrections_export_has_human_readable_filename() -> None: + assert _export_filename("ГК Русский бисквит", 2024, "Q3") == ("Корректировки_ГК Русский бисквит_2024_Q3.xlsx") + + +async def test_existing_draft_keeps_original_amounts( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +) -> None: + world = await real_trade(seeded, fixtures_dir) + await configure_trade_source(correction_source, seeded, world, fixtures_dir) + rule = ( + await seeded.scalars( + select(EliminationRule).where( + EliminationRule.rule_no == TRADE_RULE_NO, + EliminationRule.statement_type == StatementType.balance, + EliminationRule.version_no == 1, + ) + ) + ).one() + now = datetime.datetime.now(datetime.UTC) + seeded.add( + Correction( + consolidation_version_id=world.version.id, + statement_type=StatementType.balance, + rule_id=rule.id, + legal_entity_id=world.a.id, + counterparty_id=world.b.id, + debit_line_code="1230", + debit_adjustment=Decimal("42"), + comment="Черновик", + status=CorrectionStatus.DRAFT, + version_no=1, + created_at=now, + updated_at=now, + ) + ) + await seeded.flush() + response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections") + assert response.status_code == HTTP_OK, response.text + aggregate = response.json()["aggregates"][0] + assert aggregate["side_1"]["amount"] == "106311116.46" + assert aggregate["correction"]["debit"] == [{"line_code": "1230", "amount": "42.00"}] + assert aggregate["correction"]["balance"]["remainder_1"] == "106311074.46" + assert response.json()["totals"]["correction_debit"] == "0.00" diff --git a/api/tests/test_api_corrections_mutations.py b/api/tests/test_api_corrections_mutations.py new file mode 100644 index 0000000..0a612bc --- /dev/null +++ b/api/tests/test_api_corrections_mutations.py @@ -0,0 +1,342 @@ +"""Сохранение APPLIED поверх реальных сумм ОСВ: замена, валидация и optimistic locking.""" + +import asyncio +from decimal import Decimal +from pathlib import Path + +import pytest +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import DomainValidationError +from src.models.audit_ex import AuditEvent +from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion +from src.models.enums import ( + AuditAction, + ConsolidationStatus, + DocumentStatus, + DocumentType, + PeriodQuarter, + StatementType, +) +from src.models.files import Document +from src.models.kfo import Correction +from src.models.statements import FinancialStatementMeta +from tests.conftest import AuthClientFactory +from tests.fixtures_helpers import GOLDEN_STATEMENTS, golden_result_v1, map_golden_snapshot +from tests.test_api_consolidated_osv import KA_RF +from tests.test_api_consolidation_locks import LOCK_WAIT_SECONDS, _wait_for_lock_waiter +from tests.test_api_corrections import MutableCorrectionSource, configure_trade_source, real_trade + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] +INITIAL_ARTICLE_COUNT, SECOND_REVISION = 3, 2 +OK, CREATED, INVALID, CONFLICT = 200, 201, 422, 409 + + +async def draft_world(session: AsyncSession, fixtures_dir: Path, source: MutableCorrectionSource): + world = await real_trade(session, fixtures_dir) + await configure_trade_source(source, session, world, fixtures_dir) + # Коды — из настоящих годовых форм 2024, включая детализацию 1231 только у group. + # Здесь проверяется сохранение статей, не выбор периодов ОСВ: тестовый период + # свода с известными реальными суммами переносим на FY, чтобы формы были совместимы. + consolidation = await session.get(Consolidation, world.version.consolidation_id) + consolidation.quarter = PeriodQuarter.Q4 + for document in ( + await session.scalars( + select(Document) + .join(ConsolidationDocument) + .where(ConsolidationDocument.consolidation_version_id == world.version.id) + ) + ).all(): + document.period_quarter = PeriodQuarter.Q4 + for entity, folder, filename in ( + (world.a, "unagrande-group", "ББ. Лист 1 .json"), + (world.b, "unagrande-co", "Б.Б Лист 1.json"), + ): + documents = await map_golden_snapshot( + session, + entity=entity, + path=fixtures_dir / GOLDEN_STATEMENTS / folder / "2024-FY" / filename, + uploaded_by=None, + result_builder=golden_result_v1, + ) + for document in documents: + if document.document_type == DocumentType.BALANCE: + document.status = DocumentStatus.CONFIRMED + session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) + await session.flush() + return world + + +def draft_body(): + return dict( + debit=[dict(line_code="1230", amount="100.00")], + credit=[dict(line_code="1520", amount="70.00"), dict(line_code="1510", amount="20.00")], + comment=" Разнесение задолженности ", + expected_version_no=None, + ) + + +async def draft_url(client, version_id): + base = f"/consolidations/versions/{version_id}" + aggregate = (await client.get(base + "/corrections")).json()["aggregates"][0] + assert (await client.post(base + "/lock")).status_code == CREATED + path = ( + f"{base}/corrections/aggregates/{aggregate['rule_id']}/" + f"{aggregate['company_1']['id']}/{aggregate['company_2']['id']}" + ) + return path, aggregate + + +async def test_create_replace_draft_and_conflict( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + client = await auth_client(KA_RF) + path, initial = await draft_url(client, world.version.id) + saved = await client.put(path, json=draft_body()) + assert saved.status_code == OK, saved.text + aggregate = saved.json() + assert aggregate["side_1"] == initial["side_1"] + assert aggregate["side_2"] == initial["side_2"] + correction = aggregate["correction"] + assert correction["version_no"] == 1 + assert correction["status"] == "APPLIED" + assert correction["comment"] == "Разнесение задолженности" + assert correction["balance"]["difference"] == "10.00" + assert correction["balance"]["remainder_1"] == "106311016.46" + rows = list((await seeded.scalars(select(Correction))).all()) + roots = {row.id for row in rows if row.parent_correction_id is None} + assert len(rows) == INITIAL_ARTICLE_COUNT + assert all(row.parent_correction_id in roots for row in rows if row.parent_correction_id) + stale = await client.put(path, json=draft_body()) + assert stale.status_code == CONFLICT + assert stale.json()["detail"]["code"] == "CORRECTION_VERSION_CONFLICT" + body = draft_body() + body.update(debit=[dict(line_code="1231", amount="5.01")], credit=[], expected_version_no=1) + replaced = await client.put(path, json=body) + assert replaced.status_code == OK, replaced.text + assert replaced.json()["correction"]["version_no"] == SECOND_REVISION + assert replaced.json()["correction"]["credit"] == [] + rows = list((await seeded.scalars(select(Correction))).all()) + assert len(rows) == 1 and rows[0].id in roots + events = list( + ( + await seeded.scalars( + select(AuditEvent) + .where(AuditEvent.action.in_([AuditAction.CORRECTION_CREATE, AuditAction.CORRECTION_EDIT])) + .order_by(AuditEvent.occurred_at) + ) + ).all() + ) + assert [e.action for e in events] == [AuditAction.CORRECTION_CREATE, AuditAction.CORRECTION_EDIT] + assert events[0].before_value is None + assert events[1].before_value["version_no"] == 1 + assert events[1].after_value["version_no"] == SECOND_REVISION + + +@pytest.mark.parametrize( + "change,code", + [ + ({"comment": " \n "}, "COMMENT_REQUIRED"), + ({"debit": [], "credit": []}, "EMPTY_CORRECTION"), + ({"credit": [{"line_code": "1231", "amount": "1"}]}, "INVALID_LINE_CODE"), + ({"debit": [{"line_code": "999999", "amount": "1"}]}, "INVALID_LINE_CODE"), + ({"debit": [{"line_code": "1230", "amount": "-1"}]}, None), + ({"debit": [{"line_code": "1230", "amount": "0.001"}]}, None), + ({"debit": [{"line_code": "1230", "amount": "10000000000000000"}]}, None), + ({"comment": "x" * 1001}, None), + ], +) +async def test_invalid_draft_does_not_write( # noqa: PLR0913, PLR0917 - fixtures and parametrization + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, + change, + code, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + client = await auth_client(KA_RF) + path, _ = await draft_url(client, world.version.id) + body = draft_body() + body.update(change) + result = await client.put(path, json=body) + assert result.status_code == INVALID, result.text + if code: + assert result.json()["detail"]["code"] == code + else: + assert isinstance(result.json()["detail"], list) + assert await seeded.scalar(select(func.count()).select_from(Correction)) == 0 + + +@pytest.mark.parametrize("updating", [False, True]) +async def test_concurrent_drafts_have_one_winner( + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_client: AuthClientFactory, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, + updating: bool, +): + async with committed_sessionmaker() as setup, setup.begin(): + world = await draft_world(setup, fixtures_dir, correction_source) + version_id = world.version.id + client = await committed_client(KA_RF) + path, _ = await draft_url(client, version_id) + body = draft_body() + if updating: + assert (await client.put(path, json=body)).status_code == OK + body["expected_version_no"] = 1 + async with committed_sessionmaker() as holder, holder.begin(): + await holder.scalar(select(ConsolidationVersion).where(ConsolidationVersion.id == version_id).with_for_update()) + first = asyncio.create_task(client.put(path, json={**body, "comment": "Редактор 1"})) + second = asyncio.create_task(client.put(path, json={**body, "comment": "Редактор 2"})) + await _wait_for_lock_waiter(committed_sessionmaker) + assert not first.done() and not second.done() + responses = await asyncio.wait_for(asyncio.gather(first, second), LOCK_WAIT_SECONDS) + assert sorted(r.status_code for r in responses) == [OK, CONFLICT] + winner = next(r.json() for r in responses if r.status_code == OK) + loser = next(r.json() for r in responses if r.status_code == CONFLICT) + assert loser["detail"]["code"] == "CORRECTION_VERSION_CONFLICT" + expected_revision = SECOND_REVISION if updating else 1 + assert winner["correction"]["version_no"] == expected_revision + async with committed_sessionmaker() as verify: + rows = list((await verify.scalars(select(Correction))).all()) + assert len(rows) == INITIAL_ARTICLE_COUNT + assert {row.version_no for row in rows} == {expected_revision} + assert {row.comment for row in rows} == {winner["correction"]["comment"]} + events = list( + ( + await verify.scalars( + select(AuditEvent).where( + AuditEvent.action.in_([AuditAction.CORRECTION_CREATE, AuditAction.CORRECTION_EDIT]) + ) + ) + ).all() + ) + assert len(events) == expected_revision + + +async def test_draft_and_audit_roll_back_together( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, + monkeypatch: pytest.MonkeyPatch, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + client = await auth_client(KA_RF) + path, _ = await draft_url(client, world.version.id) + assert (await client.put(path, json=draft_body())).status_code == OK + original = RequestAudit.record + + async def failing_record(self, **kwargs): + event = await original(self, **kwargs) + if kwargs["action"] == AuditAction.CORRECTION_EDIT: + raise DomainValidationError("TEST_ROLLBACK", "Ошибка после записи аудита") + return event + + monkeypatch.setattr(RequestAudit, "record", failing_record) + body = draft_body() + body.update(expected_version_no=1, debit=[], comment="Не сохранять") + result = await client.put(path, json=body) + assert result.status_code == INVALID + table = (await client.get(f"/consolidations/versions/{world.version.id}/corrections")).json() + assert table["aggregates"][0]["correction"]["version_no"] == 1 + assert len(table["aggregates"][0]["correction"]["debit"]) == 1 + assert await seeded.scalar(select(func.count()).select_from(Correction)) == INITIAL_ARTICLE_COUNT + assert ( + await seeded.scalar( + select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.CORRECTION_EDIT) + ) + == 0 + ) + + +async def test_correction_guards_and_save_applies( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + client = await auth_client(KA_RF) + path, _ = await draft_url(client, world.version.id) + base = f"/consolidations/versions/{world.version.id}" + await client.delete(base + "/lock") + unlocked = await client.put(path, json=draft_body()) + assert unlocked.status_code == CONFLICT + assert unlocked.json()["detail"]["code"] == "LOCK_REQUIRED" + assert (await client.post(base + "/lock")).status_code == CREATED + applied = await client.put(path, json=draft_body()) + assert applied.status_code == OK + assert applied.json()["correction"]["status"] == "APPLIED" + body = draft_body() + del body["expected_version_no"] + assert (await client.put(path, json=body)).status_code == INVALID + world.version.status = ConsolidationStatus.VERIFIED + await seeded.flush() + readonly = await client.put(path, json=draft_body()) + assert readonly.status_code == CONFLICT + assert readonly.json()["detail"]["code"] == "VERSION_NOT_EDITABLE" + assert await seeded.scalar(select(func.count()).select_from(Correction)) == INITIAL_ARTICLE_COUNT + + +async def test_draft_numeric_boundary_and_missing_company_form( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + client = await auth_client(KA_RF) + path, _ = await draft_url(client, world.version.id) + body = draft_body() + body.update(debit=[dict(line_code="1230", amount="9999999999999999.99")], credit=[]) + saved = await client.put(path, json=body) + assert saved.status_code == OK, saved.text + assert saved.json()["correction"]["debit"][0]["amount"] == "9999999999999999.99" + # Форма компании 2 более не привязана: шаблон чужой компании не даёт права на статью. + documents = select(FinancialStatementMeta.document_id).join( + Document, + Document.id == FinancialStatementMeta.document_id, + ) + await seeded.execute( + delete(ConsolidationDocument).where( + ConsolidationDocument.consolidation_version_id == world.version.id, + ConsolidationDocument.document_id.in_(documents.where(Document.legal_entity_id == world.b.id)), + ) + ) + invalid = await client.put(path, json={**draft_body(), "expected_version_no": 1}) + assert invalid.status_code == INVALID + assert invalid.json()["detail"]["code"] == "INVALID_LINE_CODE" + assert await seeded.scalar(select(func.count()).select_from(Correction)) == 1 + + +async def test_large_source_deviation_does_not_limit_draft( + auth_client: AuthClientFactory, + seeded: AsyncSession, + fixtures_dir: Path, + correction_source: MutableCorrectionSource, +): + world = await draft_world(seeded, fixtures_dir, correction_source) + aggregate = correction_source.tables[StatementType.balance].aggregates[0] + aggregate.side_2.amount = Decimal("1.00") + aggregate.deviation_amount = aggregate.side_1.amount - aggregate.side_2.amount + aggregate.deviation_percent = aggregate.deviation_amount / aggregate.side_2.amount * 100 + aggregate.exceeds_threshold = True + aggregate.status = "DISCREPANCY" + aggregate.eliminated_amount = Decimal("0") + client = await auth_client(KA_RF) + path, initial = await draft_url(client, world.version.id) + saved = await client.put(path, json=draft_body()) + assert saved.status_code == OK, saved.text + assert saved.json()["deviation_percent"] == initial["deviation_percent"] + rows = list((await seeded.scalars(select(Correction))).all()) + assert rows and all(row.deviation_percent is None for row in rows) + event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.CORRECTION_CREATE))).one() + assert Decimal(event.after_value["source_deviation_percent"]) == Decimal(initial["deviation_percent"]) diff --git a/api/tests/test_correction_source.py b/api/tests/test_correction_source.py new file mode 100644 index 0000000..e09647f --- /dev/null +++ b/api/tests/test_correction_source.py @@ -0,0 +1,74 @@ +"""Сборка агрегатов из уже готовых назначений ОСВ.""" + +import uuid +from decimal import Decimal +from types import SimpleNamespace + +from src.services.correction_source import ( + VgoAssignmentCorrectionSource, + _AggregateGroup, + _company_no, + _EntryGroup, + _RuleSpec, +) + +SECOND_COMPANY = 2 + + +def _rule(**changes): + values = dict( + id=uuid.uuid4(), + rule_no=6, + article_1_line="1230", + article_2_line="1520", + side_1="D", + measure="closing", + elimination_mode="min", + ) + values.update(changes) + return SimpleNamespace(**values) + + +def test_assigned_entries_form_oriented_company_aggregate() -> None: + first = SimpleNamespace(id=uuid.uuid4(), name="Компания А") + second = SimpleNamespace(id=uuid.uuid4(), name="Компания Б") + rule = _rule() + group = _AggregateGroup(rule, first, second) + group.entries[1][(first.id, second.id, "62.01", "D")] = _EntryGroup( + [uuid.UUID(int=2), uuid.UUID(int=1)], Decimal("100.00") + ) + group.entries[2][(second.id, first.id, "60.01", "K")] = _EntryGroup([uuid.UUID(int=3)], Decimal("95.00")) + + aggregate = VgoAssignmentCorrectionSource._aggregate(group) + + assert (aggregate.company_1.id, aggregate.company_2.id) == (first.id, second.id) + assert aggregate.side_1.amount == Decimal("100.00") + assert aggregate.side_2.amount == Decimal("95.00") + assert aggregate.deviation_percent == Decimal("5") / Decimal("95") * 100 + assert aggregate.exceeds_threshold + assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1), uuid.UUID(int=2)] + + +def test_overlapping_rule_accounts_use_nonzero_rule_direction() -> None: + account_id = uuid.uuid4() + rule = _rule() + spec = _RuleSpec(rule) + spec.included[1].add(account_id) + spec.included[2].add(account_id) + debit_entry = SimpleNamespace( + account_id=account_id, + closing_debit=Decimal("10"), + closing_credit=Decimal("0"), + turnover_debit=Decimal("0"), + turnover_credit=Decimal("0"), + ) + credit_entry = SimpleNamespace( + account_id=account_id, + closing_debit=Decimal("0"), + closing_credit=Decimal("10"), + turnover_debit=Decimal("0"), + turnover_credit=Decimal("0"), + ) + + assert _company_no(spec, debit_entry) == 1 + assert _company_no(spec, credit_entry) == SECOND_COMPANY diff --git a/api/tests/test_corrections_domain.py b/api/tests/test_corrections_domain.py new file mode 100644 index 0000000..9a89357 --- /dev/null +++ b/api/tests/test_corrections_domain.py @@ -0,0 +1,47 @@ +"""Контроль агрегата нового API: строгий порог до округления, ручные правила.""" + +from decimal import Decimal +from pathlib import Path + +import pytest + +from src.domain.corrections import calculate_totals +from tests.test_intercompany_matrix_golden import _source + + +@pytest.mark.fixtures +def test_real_exact_trade_pair(fixtures_dir: Path) -> None: + debit = _source(fixtures_dir, "schastl", "62", 21) + credit = _source(fixtures_dir, "rb", "60", 409) + result = calculate_totals(Decimal(debit["closing_debit"]), Decimal(credit["closing_credit"])) + assert result.eliminated_amount == Decimal("106311116.46") + assert result.deviation_percent == 0 + assert result.status == "AUTO" + + +@pytest.mark.fixtures +@pytest.mark.parametrize(("ratio", "exceeds"), [("1.049999999", False), ("1.05", False), ("1.050000001", True)]) +def test_threshold_before_display_rounding(fixtures_dir: Path, ratio: str, exceeds: bool) -> None: + amount = Decimal(_source(fixtures_dir, "schastl", "62", 21)["closing_debit"]) + result = calculate_totals(amount, amount * Decimal(ratio)) + assert result.exceeds_threshold is exceeds + assert result.eliminated_amount == (0 if exceeds else amount) + + +@pytest.mark.fixtures +@pytest.mark.parametrize("factor", ["0", "-1"]) +def test_nonpositive_side_has_no_percentage_or_elimination(fixtures_dir: Path, factor: str) -> None: + amount = Decimal(_source(fixtures_dir, "rb", "66", 25)["closing_credit"]) + result = calculate_totals(amount * Decimal(factor), amount) + assert result.deviation_percent is None + assert result.eliminated_amount == 0 + assert result.exceeds_threshold + assert result.status == ("ONE_SIDED" if factor == "0" else "DISCREPANCY") + + +@pytest.mark.fixtures +def test_special_manual_rules_do_not_inherit_min(fixtures_dir: Path) -> None: + amount = Decimal(_source(fixtures_dir, "rb", "66", 25)["closing_credit"]) + result = calculate_totals(amount, amount, manual=True) + assert result.eliminated_amount == 0 + assert result.status == "MANUAL" diff --git a/api/tests/test_corrections_export.py b/api/tests/test_corrections_export.py new file mode 100644 index 0000000..e1879e9 --- /dev/null +++ b/api/tests/test_corrections_export.py @@ -0,0 +1,135 @@ +"""XLSX и JSON таблицы на реальных суммах ОСВ РБ/Счастливочной.""" + +import datetime +from decimal import Decimal +from io import BytesIO +from pathlib import Path +from uuid import UUID + +import pytest +from openpyxl import load_workbook + +from src.schemas.corrections import CorrectionOut, CorrectionTableOut +from src.services.corrections_export import build_workbook +from tests.test_intercompany_matrix_golden import BAKERY, RB, _source + +pytestmark = pytest.mark.fixtures + + +@pytest.fixture +def table(fixtures_dir: Path) -> CorrectionTableOut: + debit = _source(fixtures_dir, "schastl", "62", 21) + credit = _source(fixtures_dir, "rb", "60", 409) + amount = Decimal(debit["closing_debit"]) + company_1, company_2 = {"id": BAKERY, "name": "Счастливочная"}, {"id": RB, "name": "Русский бисквит"} + rule_id = UUID(int=6) + + def entry(source: dict, *, side: str, entry_id: int) -> dict: + return dict( + entry_ids=[UUID(int=entry_id)], + legal_entity_id=BAKERY if side == "D" else RB, + counterparty_id=RB if side == "D" else BAKERY, + account_code=source["account_code_raw"], + side=side, + amount=Decimal(source["closing_debit" if side == "D" else "closing_credit"]), + ) + + return CorrectionTableOut.model_validate( + dict( + version_id=UUID(int=1), + statement_type="balance", + rules_version_no=1, + editable=True, + read_only_reason=None, + rules=[], + aggregates=[ + dict( + rule_id=rule_id, + rule_no=6, + company_1=company_1, + company_2=company_2, + side_1=dict( + article_line="1230", + accounts=["62.01"], + amount=amount, + entries=[ + entry(debit, side="D", entry_id=2), + ], + ), + side_2=dict( + article_line="1520", + accounts=["60.01"], + amount=amount, + entries=[entry(credit, side="K", entry_id=4)], + ), + deviation_amount=0, + deviation_percent=0, + exceeds_threshold=False, + status="AUTO", + eliminated_amount=amount, + correction=None, + ) + ], + totals=dict(amount_1=amount, amount_2=amount, deviation_amount=0, correction_debit=0, correction_credit=0), + ) + ) + + +def test_export_preserves_rubles_and_detail_rows(table: CorrectionTableOut) -> None: + workbook = load_workbook(BytesIO(build_workbook(table)), data_only=True) + try: + main = workbook["Корректировки"] + assert workbook.sheetnames == ["Корректировки"] + assert Decimal(str(main["E2"].value)) == table.totals.amount_1 == Decimal("106311116.46") + assert Decimal(str(main["I2"].value)) == table.totals.amount_2 + assert main["A3"].value is None + assert main["B3"].value == "Счастливочная" + assert main["D3"].value == "62.01" + assert main.row_dimensions[3].outlineLevel == 1 + assert main["C2"].value == "1230" + finally: + workbook.close() + + +def test_export_writes_only_applied_corrections(table: CorrectionTableOut) -> None: + amount = table.totals.amount_1 + table.aggregates[0].correction = CorrectionOut.model_validate( + dict( + status="APPLIED", + version_no=1, + debit=[dict(line_code="1230", amount=amount)], + credit=[dict(line_code="1520", amount=amount)], + comment="=SUM(A1:A2)", + reason_code=None, + balance=dict( + debit_total=amount, + credit_total=amount, + difference=0, + difference_percent=0, + remainder_1=0, + remainder_2=0, + ), + updated_by=None, + updated_at=datetime.datetime.now(datetime.UTC), + ) + ) + for status in ("APPLIED", "DRAFT"): + table.aggregates[0].correction.status = status + workbook = load_workbook(BytesIO(build_workbook(table)), data_only=False) + try: + main = workbook["Корректировки"] + if status == "APPLIED": + assert Decimal(str(main["L2"].value)) == amount + assert main["P2"].data_type == "s" + assert main["P2"].value == "=SUM(A1:A2)" + else: + assert main["L2"].value is None + assert main["P2"].value is None + finally: + workbook.close() + + +def test_money_json_has_two_decimal_places(table: CorrectionTableOut) -> None: + aggregate = table.model_dump(mode="json")["aggregates"][0] + assert aggregate["deviation_percent"] == "0.00" + assert aggregate["side_1"]["amount"] == "106311116.46" diff --git a/api/tests/test_corrections_golden.py b/api/tests/test_corrections_golden.py new file mode 100644 index 0000000..4b1df33 --- /dev/null +++ b/api/tests/test_corrections_golden.py @@ -0,0 +1,45 @@ +"""Реальные входы таблицы корректировок, Срез 5 / AURORA-1540. + +Источник: golden/expected_osv_parse/{rb,schastl}/2024-Q3 в fixtures. +Загрузчик общий с проверками шахматки: выбирает документ/счёт/строку и БУ. +106311116.46 — встречная задолженность по правилу №6, не сумма всего +агрегата (у Счастливочной есть другие позиции по тому же правилу). +42000000.00 — только кредит 66.03 РБ: встречной ОСВ 58.03 в наборе нет. +Не подменяем отсутствие источника синтетической дебетовой строкой. +""" + +from decimal import Decimal +from pathlib import Path + +import pytest + +from tests.test_intercompany_matrix_golden import _source + +pytestmark = pytest.mark.fixtures + + +@pytest.fixture +def correction_source_rows(fixtures_dir: Path) -> dict[str, dict]: + """Исходные строки с неизменёнными суммами, счетами и именами контрагентов.""" + return { + "receivable": _source(fixtures_dir, "schastl", "62", 21), + "payable": _source(fixtures_dir, "rb", "60", 409), + "loan_payable": _source(fixtures_dir, "rb", "66", 25), + } + + +def test_correction_trade_sources_are_reciprocal(correction_source_rows: dict[str, dict]) -> None: + debit = correction_source_rows["receivable"] + credit = correction_source_rows["payable"] + assert (debit["account_code_raw"], credit["account_code_raw"]) == ("62.01", "60.01") + assert debit["counterparty_inn"] == "3528052718" + assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"] + assert Decimal(debit["closing_debit"]) == Decimal(credit["closing_credit"]) == Decimal("106311116.46") + + +def test_correction_loan_source_is_one_sided(correction_source_rows: dict[str, dict]) -> None: + credit = correction_source_rows["loan_payable"] + assert credit["account_code_raw"] == "66.03" + assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"] + assert Decimal(credit["closing_credit"]) == Decimal("42000000.00") + assert Decimal(credit["closing_debit"] or "0") == 0 diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 75c5255..ad31920 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -76,6 +76,12 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/osv/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"}, "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"}, + "/api/v1/consolidations/versions/{version_id}/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}": { + "put" + }, + # Корректировки, первый этап + "/api/v1/consolidations/versions/{version_id}/corrections": {"get"}, + "/api/v1/consolidations/versions/{version_id}/corrections/export": {"get"}, # шахматка ВГО и правила элиминации "/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"}, diff --git a/openspec/changes/corrections-api-stage-one/.openspec.yaml b/openspec/changes/corrections-api-stage-one/.openspec.yaml new file mode 100644 index 0000000..5c99d6d --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-29 diff --git a/openspec/changes/corrections-api-stage-one/design.md b/openspec/changes/corrections-api-stage-one/design.md new file mode 100644 index 0000000..5b16a23 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/design.md @@ -0,0 +1,43 @@ +# Design + +## Context + +См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения разрабатываются отдельно; этот change не должен дублировать их алгоритм или хранение. + +## Goals / Non-Goals + +**Goals:** + +- Рассчитать контроль отклонения и справочную элиминацию для переданных агрегатов. +- Прочитать и атомарно сохранить корректировку в существующей модели. +- Обеспечить блокировки, optimistic version, аудит и XLSX-экспорт. + +**Non-Goals:** + +- Формировать агрегаты из ОСВ, выбирать правило или хранить назначения. +- Изменять DBML, добавлять ORM-модель или миграцию. +- Импортировать XLSX, рассчитывать КФО, поддерживать ОФР или UI. + +## Decisions + +1. **Явная граница по `vgo_assignment`.** Сервис корректировок читает готовые назначения соседней задачи и группирует строки по правилу и ориентированной паре компаний. Он не создаёт и не изменяет назначения. Если назначений нет, возвращается пустая таблица. + +2. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`. + +3. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна. + +4. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна. + +5. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит. + +6. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Строки ОСВ следуют за агрегатом с outline level 1; колонка «Правило» у них пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости. + +## Risks / Trade-offs + +- [Назначения ещё не рассчитаны соседней задачей] → API возвращает пустую таблицу; локальный алгоритм выбора правил не добавляется. +- [Состав агрегата меняется в соседней задаче] → lifecycle `DRAFT/REVOKED` по изменению состава согласуется и реализуется там либо отдельным change; этот этап не отслеживает переносы строк. +- [Ровно 5 % ранее трактовалось как превышение] → зафиксировать `≤ 5 %` отдельным граничным тестом. + +## Migration Plan + +Используется миграция `0011_vgo_assignment` из ветки AURORA-1751. Rollback удаляет таблицу назначений вместе с API-кодом этого этапа. diff --git a/openspec/changes/corrections-api-stage-one/proposal.md b/openspec/changes/corrections-api-stage-one/proposal.md new file mode 100644 index 0000000..1b62021 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +Аналитику нужна агрегатная таблица корректировок ББ: видеть исходные суммы уже сформированных агрегатов, контролировать отклонение и сохранять обоснованную корректировку по статьям баланса. Текущий backend не предоставляет единого API для этого сценария. + +## What Changes + +- Добавить сборку агрегатной таблицы корректировок ББ из готовых назначений `vgo_assignment`: исходные суммы, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска. +- Добавить атомарное сохранение корректировки агрегата в существующую модель `correction`; комментарий обязателен при каждом сохранении, результат получает статус `APPLIED`. +- Поддержать дочерние статьи обеих сторон, контроль редакции, блокировку версии, запрет изменения после верификации и аудит `CORRECTION_CREATE/EDIT`. +- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые исходные строки, итог и нераспределённые данные, полученные от источника таблицы. +- Использовать рубли и `Decimal`; процент считать к меньшей положительной сумме до экранного округления. + +## Capabilities + +### New Capabilities + +- `corrections-api`: чтение, сохранение и экспорт агрегатных корректировок ББ. + +### Modified Capabilities + +Нет. + +## Impact + +Затрагивается `arm-core`: миграция `vgo_assignment` из AURORA-1751, ORM-модель и чтение назначений, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт. + +## Non-goals + +- Создание и изменение `vgo_assignment`, автоматический выбор правила, ручной перенос и исключение — отдельная задача; этот change только читает готовые назначения. +- Импорт XLSX исключён решением аналитика 29.10.2026. +- UI, расчёт/инвалидация КФО, ОФР и изменение статусов согласования не входят в этап. diff --git a/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md new file mode 100644 index 0000000..eae2c5c --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md @@ -0,0 +1,80 @@ +# Spec Delta + +## Purpose + +Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок уже сформированных агрегатов бухгалтерского баланса. + +## ADDED Requirements + +### Requirement: Чтение таблицы корректировок +Система SHALL собирать и возвращать агрегаты ББ из готовых `vgo_assignment` с компаниями, статьями, счетами, исходными строками, суммами, отклонением и сохранённой корректировкой. GET MUST NOT изменять назначения или другие данные. + +#### Scenario: Чтение доступной версии +- **WHEN** пользователь с правом просмотра запрашивает таблицу доступной версии +- **THEN** система возвращает агрегаты и исходные строки в стабильном порядке +- **THEN** в БД не создаются назначения или корректировки + +#### Scenario: Готовые назначения отсутствуют +- **WHEN** для версии нет строк `vgo_assignment` с назначенным правилом +- **THEN** система возвращает пустую таблицу без самостоятельного разнесения ОСВ + +### Requirement: Расчёт отклонения и элиминации +Система SHALL считать абсолютное отклонение и процент к меньшей положительной сумме без предварительного округления. При отклонении не более 5 % справочная сумма элиминации SHALL равняться меньшей сумме; остаток SHALL относиться к большей статье. + +#### Scenario: Отклонение не более пяти процентов +- **WHEN** меньшая сторона положительна и отклонение составляет ровно 5 % или меньше +- **THEN** сумма элиминации равна меньшей стороне +- **THEN** разница остаётся в большей статье + +#### Scenario: Отклонение больше пяти процентов +- **WHEN** отклонение больше 5 % +- **THEN** агрегат помечается как требующий корректировки + +#### Scenario: Нулевая меньшая сторона +- **WHEN** одна сторона равна нулю +- **THEN** процент отклонения равен `null` +- **THEN** агрегат считается односторонним + +### Requirement: Сохранение корректировки агрегата +Система SHALL атомарно заменять корневые и дочерние строки корректировки существующего агрегата. Комментарий после trim MUST быть непустым при каждом сохранении. Успешное сохранение SHALL устанавливать статус `APPLIED`. + +#### Scenario: Сохранение с комментарием +- **WHEN** аналитик сохраняет допустимые суммы и статьи обеих сторон с непустым комментарием +- **THEN** система полностью заменяет набор корректировки и возвращает `APPLIED` +- **THEN** создаётся аудит `CORRECTION_CREATE` или `CORRECTION_EDIT` + +#### Scenario: Пустой комментарий +- **WHEN** аналитик сохраняет корректировку с пустым или состоящим из пробелов комментарием +- **THEN** система возвращает `COMMENT_REQUIRED` +- **THEN** ни одна строка корректировки не изменяется + +#### Scenario: Ошибка одной дочерней строки +- **WHEN** одна из сумм или статей недопустима +- **THEN** весь запрос отклоняется без частичной записи + +### Requirement: Конкурентное и статусное ограничение +Система MUST разрешать изменение только владельцу активной блокировки редактируемой версии. Клиент SHALL передавать ожидаемую редакцию существующей корректировки. + +#### Scenario: Конфликт редакции +- **WHEN** ожидаемая редакция не совпадает с текущей +- **THEN** система возвращает `CORRECTION_VERSION_CONFLICT` без записи + +#### Scenario: Верифицированная версия +- **WHEN** пользователь пытается сохранить корректировку после верификации версии +- **THEN** система отклоняет изменение + +### Requirement: XLSX-экспорт корректировок +Система SHALL экспортировать таблицу одним листом XLSX. Строка агрегата SHALL сопровождаться следующими за ней раскрываемыми исходными строками; колонка правила у исходной строки SHALL быть пустой. + +#### Scenario: Экспорт ББ +- **WHEN** пользователь экспортирует таблицу корректировок +- **THEN** файл называется `Корректировки_<название ГК>_<год>_<квартал>.xlsx` +- **THEN** книга содержит один лист с агрегатами, детализацией и итогами в рублях +- **THEN** система создаёт событие аудита `EXPORT` + +### Requirement: Граница этапа +Система MUST NOT предоставлять в этом capability импорт XLSX, автоматическое разнесение строк ОСВ, ручной перенос строк между правилами или операции изменения `vgo_assignment`. + +#### Scenario: Контракт этапа +- **WHEN** публикуется OpenAPI этапа корректировок +- **THEN** в нём отсутствуют операции импорта и назначения строк правилам diff --git a/openspec/changes/corrections-api-stage-one/tasks.md b/openspec/changes/corrections-api-stage-one/tasks.md new file mode 100644 index 0000000..4e92366 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/tasks.md @@ -0,0 +1,39 @@ +# Tasks + +## 1. Граница агрегатов и расчёт + +- [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ в `api/src/services/corrections.py` интерфейсом готовых агрегатов соседней задачи; удалить `domain/vgo_allocation.py` и его тесты. Проверка: OpenAPI/GET не создают назначения, при пустом источнике возвращается пустая таблица. +- [x] 1.2 [arm-core] Обновить `api/src/domain/corrections.py` и граничные тесты: при отклонении `≤ 5 %` `eliminated_amount = min`, разница остаётся в большей статье; `> 5 %` требует корректировки; нулевая меньшая сторона даёт `percent = null`. +- [x] 1.3 [arm-core] Удалить из schemas, README и тестов `unallocated`, manual decision и прочие поля, принадлежащие разнесению, если их не предоставляет утверждённый интерфейс источника. Проверка: поиск по `ASSIGN/UNASSIGN/EXCLUDE/INCLUDE` не находит контракт корректировок. + +## 2. Сохранение корректировки + +- [x] 2.1 [arm-core] Проверить и дополнить тесты `PUT …/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}`: комментарий обязателен всегда, статьи принадлежат формам компаний, суммы валидны, ошибка не оставляет частичной записи. +- [x] 2.2 [arm-core] Проверить блокировку версии, запрет правок после верификации и `expected_version_no`; успешное сохранение даёт `APPLIED` и атомарный аудит `CORRECTION_CREATE/EDIT`. + +## 3. Чтение и экспорт + +- [x] 3.1 [arm-core] Согласовать GET DTO с обновлённой спецификацией и добавить тесты стабильной сортировки, рублей, read-only поведения, 403/404 и пустого источника агрегатов. +- [x] 3.2 [arm-core] Проверить одно-листовый XLSX: агрегат и раскрываемые исходные строки, пустая колонка «Правило» у детализации, итоговые суммы и имя `Корректировки_<ГК>_<год>_<квартал>.xlsx`; проверить аудит `EXPORT`. + +## 4. Документация и проверки + +- [x] 4.1 [arm-core] Обновить README под фактические маршруты и границу этапа: без импорта, разнесения ОСВ, ручных переносов, `vgo_assignment`, КФО и ОФР. Проверка: примеры запросов соответствуют OpenAPI. +- [x] 4.2 [arm-core] Выполнить `openspec validate corrections-api-stage-one --strict`, целевые unit/integration-тесты, `make check` и `make dbml-check`; зафиксировать фактические результаты без ссылок на тесты старого объёма. + +## 5. Интеграция с распределением ОСВ + +- [x] 5.1 [arm-core] Вернуть merge AURORA-1751 с `0011_vgo_assignment`, добавить read-only SQLAlchemy mapping и построение агрегатов из готовых `ASSIGN` без создания или изменения назначений. +- [x] 5.2 [arm-core] Обновить демо-seed, README и тесты: назначенные строки образуют агрегаты, отсутствие назначений даёт пустую таблицу; повторить проверки этапа. + +### Результаты проверки 29.10.2026 + +- `openspec validate corrections-api-stage-one --strict` — успешно. +- `make check` с тестовым `DEBUG=true` — успешно: ruff, format, mypy и 1933 unit-теста. +- Целевые unit/OpenAPI-тесты корректировок — 92 успешно. +- Целевые integration-тесты корректировок — 21 сценарий собран, включая сборку агрегата из `vgo_assignment`; запуск остановлен окружением до выполнения тестов: PostgreSQL отклонил пароль пользователя `armka` из `.env.test`. +- `make dbml-check` — успешно, расхождений не выведено. + +## Workflow follow-up + +- После реализации и review архивировать change отдельным запуском OpenSpec archive. diff --git a/openspec/config.yaml b/openspec/config.yaml index df3ff4e..c5e751a 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -3,7 +3,7 @@ schema: spec-driven context: | Репозиторий arm-core — часть системы АРМ КА (ДКБ, Россельхозбанк): ядро (FastAPI + React), единственный писатель в PostgreSQL: справочники, файлы/документы, маппинг результатов распознавания, вычисления консолидации, согласование, ЕХ, аудит, права. Workspace из нескольких репозиториев; общий контекст — ../CLAUDE.md и ../docs/ (репозиторий arm-ka-docs). - Источники истины по приоритету: ../docs/model/data-model-v5.21.dbml → ../docs/model/entities-v5.21.md → + Источники истины по приоритету: ../docs/model/data-model-v5.24.dbml → ../docs/model/entities-v5.24.md → ../docs/context/05-domain-rules.md → страницы ТЗ в ../docs/source/tz (черновик, старые имена v4). Открытые вопросы — ../docs/context/07-open-questions.md; план срезов — ../docs/context/09-delivery-plan.md. Фикстуры — ../fixtures (реальные данные, не спецификация формата). @@ -14,7 +14,7 @@ context: | rules: proposal: - Указывать номер среза из 09-delivery-plan.md и затронутые репозитории; сквозные изменения — отдельный change в ../docs - - Перечислять таблицы текущей DBML (сейчас v5.21), которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML + - Перечислять таблицы текущей DBML (сейчас v5.24), которые затрагиваются; поля вне DBML не вводить — сначала новая версия DBML - Ссылаться на параграфы 05-domain-rules.md для каждого алгоритма; правило, которого там нет, — в 07-open-questions.md - Раздел «Non-goals» обязателен - Любое допущение по открытому вопросу помечать ASSUMPTION(OQ-nn) -- 2.47.2 From 9bae9314543caacecc39498b9b67f756c61ac483 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 30 Oct 2026 12:56:39 +0300 Subject: [PATCH 03/10] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B0=D0=B7=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=81=D0=B2=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=D0=BC=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B5=20+=20=D0=BD=D0=B0=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/dependencies/consolidations.py | 11 + .../api/endpoints/consolidations/__init__.py | 2 + .../consolidations/vgo_assignment.py | 22 ++ api/src/models/kfo.py | 46 ++- api/src/repositories/vgo_assignments.py | 24 ++ api/src/schemas/consolidations.py | 41 +++ api/src/services/intercompany_matrix.py | 26 +- api/src/services/vgo.py | 271 +++++++++++++++++ api/tests/test_domain_consolidated_osv.py | 17 ++ api/tests/test_intercompany_matrix.py | 31 +- api/tests/test_vgo.py | 279 ++++++++++++++++++ 11 files changed, 762 insertions(+), 8 deletions(-) create mode 100644 api/src/api/endpoints/consolidations/vgo_assignment.py create mode 100644 api/src/repositories/vgo_assignments.py create mode 100644 api/src/services/vgo.py create mode 100644 api/tests/test_vgo.py diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 600689a..cc61291 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -16,6 +16,7 @@ from src.services.consolidations import ConsolidationService from src.services.counterparty_mapping import CounterpartyMappingService from src.services.elimination_rules import EliminationRuleService from src.services.intercompany_matrix import IntercompanyMatrixService +from src.services.vgo import VgoService def get_consolidation_service( @@ -97,3 +98,13 @@ def get_intercompany_matrix_service( IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)] + + +def get_vgo_service( + session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep +) -> VgoService: + """Сервис подготовки строк ВГО на основе полной сводной ОСВ версии.""" + return VgoService(session, osv_service, matrix_service) + + +VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 934c11e..9ebf379 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -14,6 +14,7 @@ from src.api.endpoints.consolidations import ( periods, status, versions, + vgo_assignment, ) router = APIRouter(tags=["consolidations"]) @@ -28,3 +29,4 @@ router.include_router(export.router) router.include_router(mapping.router) router.include_router(elimination_rule.router) router.include_router(intercompany_matrix.router) +router.include_router(vgo_assignment.router) diff --git a/api/src/api/endpoints/consolidations/vgo_assignment.py b/api/src/api/endpoints/consolidations/vgo_assignment.py new file mode 100644 index 0000000..8de7ccc --- /dev/null +++ b/api/src/api/endpoints/consolidations/vgo_assignment.py @@ -0,0 +1,22 @@ +"""Подготовка распределения строк ОСВ по статьям КФО.""" + +import uuid + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import VgoServiceDep +from src.schemas.consolidations import VgoAssignmentCandidatesOut + +router = APIRouter(prefix="/consolidations/versions") + + +@router.post( + "/{version_id}/vgo-assignments/distribute", + dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + response_model=VgoAssignmentCandidatesOut, + summary="Агрегаты ВГО по правилам и компаниям с ручными назначениями", +) +async def distribute_articles(version_id: uuid.UUID, service: VgoServiceDep) -> VgoAssignmentCandidatesOut: + """Возвращает агрегаты и ручные назначения по группам, а также строки без назначения.""" + return await service.assignment_candidates(version_id) diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index 5878668..50e4651 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -8,7 +8,7 @@ import datetime import decimal import uuid -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text +from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -16,6 +16,50 @@ from .base import Base, UUIDPrimaryMixin from .enums import CorrectionStatus, StatementType, pg_enum +class VgoAssignment(UUIDPrimaryMixin, Base): + """Выбор правила для строки ОСВ в рамках версии и типа отчётности.""" + + __tablename__ = "vgo_assignment" + __table_args__ = ( + Index( + "uq_vgo_assignment_version_type_entry", + "consolidation_version_id", + "statement_type", + "osv_entry_id", + unique=True, + ), + Index("ix_vgo_assignment_osv_entry_id", "osv_entry_id"), + CheckConstraint("action IN ('ASSIGN', 'UNASSIGNED')", name="action_values"), + CheckConstraint( + "(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)", + name="action_rule_consistency", + ), + ) + + consolidation_version_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("consolidation_version.id", name="fk_vgo_assignment_version"), nullable=False + ) + statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) + osv_entry_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("osv_entry.id", name="fk_vgo_assignment_entry"), nullable=False + ) + rule_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("elimination_rule.id", name="fk_vgo_assignment_rule") + ) + action: Mapped[str] = mapped_column(String, nullable=False) + is_exclude: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("app_user.id", name="fk_vgo_assignment_created_by") + ) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=text("now()") + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("app_user.id", name="fk_vgo_assignment_updated_by") + ) + updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) + + class Correction(UUIDPrimaryMixin, Base): """Корректировка ВГО: итог = свод первички − Σ применённых корректировок; пара — по (rule_id, компании).""" diff --git a/api/src/repositories/vgo_assignments.py b/api/src/repositories/vgo_assignments.py new file mode 100644 index 0000000..f0c3132 --- /dev/null +++ b/api/src/repositories/vgo_assignments.py @@ -0,0 +1,24 @@ +"""Ручные назначения строк ОСВ в версии консолидации.""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.enums import StatementType +from src.models.kfo import VgoAssignment + + +async def list_assignments( + session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType +) -> list[VgoAssignment]: + """Все ручные решения для версии и типа отчётности.""" + stmt = ( + select(VgoAssignment) + .where( + VgoAssignment.consolidation_version_id == version_id, + VgoAssignment.statement_type == statement_type, + ) + .order_by(VgoAssignment.created_at, VgoAssignment.id) + ) + return list((await session.scalars(stmt)).all()) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index f53eab8..b20e047 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -425,6 +425,47 @@ class EliminationPairsOut(BaseModel): return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) +class VgoAssignmentRowOut(ConsolidatedRowOut): + """Строка свода с данными ручного решения, если оно есть.""" + + osv_entry_id: uuid.UUID | None = None + action: Literal["ASSIGN", "UNASSIGNED"] | None = None + is_exclude: bool | None = None + + +class VgoAssignmentGroupOut(BaseModel): + """Принятые агрегаты и ручные строки одного правила и направленной пары компаний.""" + + rule_id: uuid.UUID + rule_no: int + article_1_line: str + article_2_line: str + measure: Literal["closing", "turnover"] + company_a_id: uuid.UUID + company_a_name: str | None + company_b_id: uuid.UUID + company_b_name: str | None + amount_a: Decimal + amount_b: Decimal + deviation_amount: Decimal + deviation_percent: Decimal | None + matched: list[EliminationMatchedPairOut] = Field(default_factory=list) + with_discrepancy: list[EliminationMatchedPairOut] = Field(default_factory=list) + manual: list[VgoAssignmentRowOut] = Field(default_factory=list, exclude_if=lambda rows: not rows) + + @field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent") + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) + + +class VgoAssignmentCandidatesOut(BaseModel): + """Принятые агрегаты и общий список строк свода, оставшихся без назначения.""" + + groups: list[VgoAssignmentGroupOut] + unassigned: list[VgoAssignmentRowOut] + + EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 1ddf739..cd3d7e3 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -43,7 +43,7 @@ from src.schemas.consolidations import ( IntercompanyResultTableOut, UnpairedReason, ) -from src.services.consolidated_osv import ConsolidatedOsvService +from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService Measure = Literal["closing", "turnover"] Side = Literal["D", "K"] @@ -553,11 +553,25 @@ class IntercompanyMatrixService: self._scope = scope self._osv_service = service_osv - async def build_pairs( - self, version_id: uuid.UUID, rule_id: uuid.UUID, company_a_id: uuid.UUID, company_b_id: uuid.UUID + async def build_pairs( # noqa: PLR0913 - четыре идентификатора маршрута и необязательный готовый свод со строками + self, + version_id: uuid.UUID, + rule_id: uuid.UUID, + company_a_id: uuid.UUID, + company_b_id: uuid.UUID, + rows: Sequence[ConsolidatedRow] | None = None, + *, + osv_result: ConsolidatedOsvBuildResult | None = None, ) -> EliminationPairsOut: - """Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5).""" - osv_result = await self._osv_service.build_with_members(version_id) + """Счета правила ББ и строки одной пары компаний без сохранения в БД (§4–§5). + + `rows` ограничивает исходные строки для поиска пар; без него используется весь свод. Готовый `osv_result` + позволяет повторно использовать уже собранный свод при расчёте нескольких правил и пар. + """ + if osv_result is None: + osv_result = await self._osv_service.build_with_members(version_id) + elif osv_result.version.id != version_id: + raise ValueError("Передан свод другой версии консолидации") version = osv_result.version member_ids = set(osv_result.members) if company_a_id == company_b_id or company_a_id not in member_ids or company_b_id not in member_ids: @@ -572,7 +586,7 @@ class IntercompanyMatrixService: # в результаты разных правил. Глобальное исключение повторного использования потребует изменения модели # данных: нужно хранить/передавать принадлежность исходной строки конкретной паре между правилами. packing = _pack_preset_rows( - osv_result.consolidated.rows, + osv_result.consolidated.rows if rows is None else rows, selection, PairContext(rule, company_a_id, company_b_id), ) diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py new file mode 100644 index 0000000..d3e259e --- /dev/null +++ b/api/src/services/vgo.py @@ -0,0 +1,271 @@ +"""Подготовка строк сводной ОСВ для распределения ВГО по правилам.""" + +import uuid +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import DomainValidationError +from src.domain.consolidated_osv import ConsolidatedRow +from src.domain.elimination_packing import calculate_rule_totals +from src.models.accounting import EliminationRule +from src.models.enums import StatementType +from src.models.kfo import VgoAssignment +from src.repositories import elimination_rules as elimination_rules_repo +from src.repositories import vgo_assignments as vgo_assignments_repo +from src.schemas.consolidations import ( + EliminationMatchedPairOut, + VgoAssignmentCandidatesOut, + VgoAssignmentGroupOut, + VgoAssignmentRowOut, +) +from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService +from src.services.intercompany_matrix import IntercompanyMatrixService + +ZERO = Decimal("0") +GroupKey = tuple[uuid.UUID, uuid.UUID, uuid.UUID] +ManualItem = tuple[VgoAssignmentRowOut, Decimal, Decimal] + + +def assignment_candidates(rows: Sequence[ConsolidatedRow]) -> list[ConsolidatedRow]: + """Оставить строки с контрагентом и ненулевым оборотом или конечным остатком. + + Все четыре суммы проверяются вместе; `None` равнозначен нулю. Начальный остаток не учитывается. + """ + candidates = [] + for row in rows: + if row.counterparty_id is None: + continue + amounts = (row.turnover_debit, row.turnover_credit, row.closing_debit, row.closing_credit) + for amount in amounts: + if amount not in (None, ZERO): + candidates.append(row) + break + return candidates + + +def _ids_from_aggregate(aggregate: EliminationMatchedPairOut) -> set[uuid.UUID]: + return set(aggregate.osv_entry_ids_a) | set(aggregate.osv_entry_ids_b) + + +def _assignment_row(row: ConsolidatedRow, assignment: VgoAssignment | None = None) -> VgoAssignmentRowOut: + output = VgoAssignmentRowOut.model_validate(row, from_attributes=True) + if assignment is not None: + output.osv_entry_id = assignment.osv_entry_id if len(row.entry_ids) == 1 else None + output.action = "ASSIGN" if assignment.action == "ASSIGN" else "UNASSIGNED" + output.is_exclude = assignment.is_exclude + return output + + +def _manual_key_amount(row: ConsolidatedRow, rule: EliminationRule) -> tuple[GroupKey, Decimal, Decimal]: + """Определить сторону ручной строки по сумме и стороне A правила.""" + if rule.measure == "turnover": + debit, credit = row.turnover_debit or ZERO, row.turnover_credit or ZERO + else: + debit, credit = row.closing_debit or ZERO, row.closing_credit or ZERO + first, second = (debit, credit) if rule.side_1 == "D" else (credit, debit) + assert row.counterparty_id is not None + if first != ZERO or second == ZERO: + return (rule.id, row.legal_entity_id, row.counterparty_id), first, ZERO + return (rule.id, row.counterparty_id, row.legal_entity_id), ZERO, second + + +@dataclass +class _ManualResult: + """Ручные решения по строкам свода и группам правил.""" + + groups: dict[GroupKey, list[ManualItem]] + by_row: dict[tuple[uuid.UUID, ...], VgoAssignment] + assigned_ids: set[uuid.UUID] + + +def _manual_assignments( + rows: Sequence[ConsolidatedRow], assignments: Sequence[VgoAssignment], rules: Sequence[EliminationRule] +) -> _ManualResult: + by_entry = {assignment.osv_entry_id: assignment for assignment in assignments} + rules_by_id = {rule.id: rule for rule in rules} + groups: defaultdict[GroupKey, list[ManualItem]] = defaultdict(list) + by_row: dict[tuple[uuid.UUID, ...], VgoAssignment] = {} + assigned_ids: set[uuid.UUID] = set() + for row in rows: + found = [by_entry[entry_id] for entry_id in row.entry_ids if entry_id in by_entry] + if not found: + continue + if len(found) != len(row.entry_ids): + raise DomainValidationError( + "AMBIGUOUS_VGO_ASSIGNMENT", + "Для части исходных строк сводной ОСВ есть ручное назначение, а для остальных нет", + ) + assignment = found[0] + if any( + (item.action, item.rule_id, item.is_exclude) + != (assignment.action, assignment.rule_id, assignment.is_exclude) + for item in found[1:] + ): + raise DomainValidationError( + "CONFLICTING_VGO_ASSIGNMENT", + "У исходных строк одной сводной ОСВ разные ручные назначения", + ) + by_row[row.entry_ids] = assignment + rule = rules_by_id.get(assignment.rule_id) if assignment.rule_id is not None else None + if assignment.action == "ASSIGN" and rule is not None and row.counterparty_id is not None: + key, amount_a, amount_b = _manual_key_amount(row, rule) + groups[key].append((_assignment_row(row, assignment), amount_a, amount_b)) + assigned_ids.update(row.entry_ids) + return _ManualResult(groups, by_row, assigned_ids) + + +def _unassigned_rows( + all_rows: Sequence[ConsolidatedRow], + candidates: Sequence[ConsolidatedRow], + manual: _ManualResult, + used_ids: set[uuid.UUID], +) -> list[VgoAssignmentRowOut]: + rows = [*candidates] + candidate_keys = {row.entry_ids for row in candidates} + rows.extend(row for row in all_rows if row.entry_ids not in candidate_keys and row.entry_ids in manual.by_row) + return [ + _assignment_row(row, manual.by_row.get(row.entry_ids)) + for row in rows + if used_ids.isdisjoint(row.entry_ids) and manual.assigned_ids.isdisjoint(row.entry_ids) + ] + + +@dataclass +class _AggregateSearchResult: + """Принятые агрегаты, кандидаты с расхождением и занятые строки.""" + + matched_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = field( + default_factory=lambda: defaultdict(list) + ) + diff_aggregates: list[EliminationMatchedPairOut] = field(default_factory=list) + used_ids: set[uuid.UUID] = field(default_factory=set) + + +def _select_diff_aggregates(search: _AggregateSearchResult) -> dict[GroupKey, list[EliminationMatchedPairOut]]: + """Принять только расхождения с единственным вариантом для набора исходных строк.""" + by_ids: defaultdict[tuple[uuid.UUID, ...], list[EliminationMatchedPairOut]] = defaultdict(list) + for aggregate in search.diff_aggregates: + ids = _ids_from_aggregate(aggregate) + if ids and search.used_ids.isdisjoint(ids): + by_ids[tuple(sorted(ids))].append(aggregate) + + diff_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = defaultdict(list) + for options in by_ids.values(): + if len(options) == 1: + aggregate = options[0] + key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) + diff_aggregates[key].append(aggregate) + search.used_ids.update(_ids_from_aggregate(aggregate)) + return diff_aggregates + + +def _build_groups( + search: _AggregateSearchResult, + diff_aggregates: dict[GroupKey, list[EliminationMatchedPairOut]], + rules: Sequence[EliminationRule], + manual: dict[GroupKey, list[ManualItem]], + names: Mapping[uuid.UUID, str], +) -> list[VgoAssignmentGroupOut]: + """Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям.""" + rule_by_id = {rule.id: rule for rule in rules} + keys = list(dict.fromkeys([*search.matched_aggregates, *diff_aggregates, *manual])) + groups = [] + for rule_id, company_a_id, company_b_id in keys: + key = (rule_id, company_a_id, company_b_id) + matched = search.matched_aggregates.get(key, []) + with_discrepancy = diff_aggregates.get(key, []) + aggregates = [*matched, *with_discrepancy] + manual_rows = manual.get(key, []) + amount_a = sum((aggregate.amount_a for aggregate in aggregates), ZERO) + amount_b = sum((aggregate.amount_b for aggregate in aggregates), ZERO) + amount_a += sum((amount_a for row, amount_a, _ in manual_rows if not row.is_exclude), ZERO) + amount_b += sum((amount_b for row, _, amount_b in manual_rows if not row.is_exclude), ZERO) + totals = calculate_rule_totals(amount_a, amount_b, manual=False) + groups.append( + VgoAssignmentGroupOut( + rule_id=rule_id, + rule_no=rule_by_id[rule_id].rule_no, + article_1_line=rule_by_id[rule_id].article_1_line, + article_2_line=rule_by_id[rule_id].article_2_line, + measure=rule_by_id[rule_id].measure, + company_a_id=company_a_id, + company_a_name=names.get(company_a_id), + company_b_id=company_b_id, + company_b_name=names.get(company_b_id), + amount_a=amount_a, + amount_b=amount_b, + deviation_amount=totals.deviation_amount, + deviation_percent=totals.deviation_percent, + matched=matched, + with_discrepancy=with_discrepancy, + manual=[row for row, _, _ in manual_rows], + ) + ) + return groups + + +class VgoService: + """Операции ВГО над сводной ОСВ версии консолидации.""" + + def __init__( + self, session: AsyncSession, osv_service: ConsolidatedOsvService, matrix_service: IntercompanyMatrixService + ) -> None: + self._osv_service = osv_service + self._matrix_service = matrix_service + self.session = session + + async def _find_aggregates( + self, + version_id: uuid.UUID, + osv_result: ConsolidatedOsvBuildResult, + rules: Sequence[EliminationRule], + rows: Sequence[ConsolidatedRow], + ) -> _AggregateSearchResult: + """Перебрать правила и компании, сразу закрепляя только точные агрегаты.""" + search = _AggregateSearchResult() + for company_a in osv_result.members: + for company_b in osv_result.members: + if company_a == company_b: + continue + for rule in rules: + free_rows = [row for row in rows if search.used_ids.isdisjoint(row.entry_ids)] + result = await self._matrix_service.build_pairs( + version_id, rule.id, company_a, company_b, free_rows, osv_result=osv_result + ) + for aggregate in result.matched: + ids = _ids_from_aggregate(aggregate) + if search.used_ids.isdisjoint(ids): + search.used_ids.update(ids) + key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) + search.matched_aggregates[key].append(aggregate) + search.diff_aggregates.extend(result.with_discrepancy) + return search + + async def assignment_candidates(self, version_id: uuid.UUID) -> VgoAssignmentCandidatesOut: + """Найти агрегаты и общие для всех правил строки без назначения, не записывая их в БД.""" + osv_result = await self._osv_service.build_with_members(version_id) + all_rules = await elimination_rules_repo.list_rules(self.session, osv_result.version.elimination_rules_version) + rules = [ + rule for rule in all_rules if rule.statement_type == StatementType.balance and rule.measure == "closing" + ] + rows = assignment_candidates(osv_result.consolidated.rows) + assignments = await vgo_assignments_repo.list_assignments(self.session, version_id, StatementType.balance) + manual = _manual_assignments(osv_result.consolidated.rows, assignments, rules) + free_rows = [row for row in rows if row.entry_ids not in manual.by_row] + search = await self._find_aggregates(version_id, osv_result, rules, free_rows) + diff_aggregates = _select_diff_aggregates(search) + + names = {company_id: entity.name for company_id, entity in osv_result.members.items()} + for row in osv_result.consolidated.rows: + if row.counterparty_id is not None: + name = row.counterparty_name or row.counterparty_entity_name + if name is not None: + names.setdefault(row.counterparty_id, name) + + groups = _build_groups(search, diff_aggregates, rules, manual.groups, names) + unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids) + return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned) diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index f385cbf..39b888a 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -27,6 +27,7 @@ from src.domain.consolidated_osv import ( ) from src.domain.osv_mapping import Resolution from src.models.enums import CounterpartyMatchKind, PeriodQuarter +from src.services.vgo import assignment_candidates Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3 INN, NAME, ALIAS, MANUAL, PROPAGATED = ( @@ -142,6 +143,22 @@ def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]: ) +def test_assignment_candidates_require_counterparty_and_any_nonzero_turnover_or_closing() -> None: + document = _doc(cumulative=True) + base = _build([document], [_row(document, counterparty_id=SCH.id)]).rows[0] + zero = dataclasses.replace(base, opening_debit=Decimal("100")) + empty = dataclasses.replace( + base, turnover_debit=None, turnover_credit=None, closing_debit=None, closing_credit=None + ) + without_counterparty = dataclasses.replace(base, counterparty_id=None, turnover_debit=Decimal("1")) + nonzero = [ + dataclasses.replace(base, **{field: Decimal("1")}) + for field in ("turnover_debit", "turnover_credit", "closing_debit", "closing_credit") + ] + + assert assignment_candidates([zero, empty, without_counterparty, *nonzero]) == nonzero + + # --------------------------------------------------------------------------- приведение периодов diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 11cb529..2719670 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -4,7 +4,7 @@ import datetime import uuid from decimal import Decimal from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from fastapi import status as http_status @@ -89,6 +89,35 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] +async def test_build_pairs_uses_supplied_rows_and_prebuilt_osv(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, rule_id, company_a, company_b = (uuid.uuid4() for _ in range(4)) + full_rows = [object()] + selected_rows: list[object] = [] + osv_result = SimpleNamespace( + version=SimpleNamespace(id=version_id), + members={company_a: object(), company_b: object()}, + consolidated=SimpleNamespace(rows=full_rows), + ) + osv_service = SimpleNamespace(build_with_members=AsyncMock(side_effect=AssertionError("свод собран повторно"))) + service = matrix.IntercompanyMatrixService(AsyncMock(), None, osv_service) + rule = SimpleNamespace(id=rule_id, rule_no=1, version_no=2, measure="closing") + service._resolve_preset_selection = AsyncMock( # type: ignore[method-assign] + return_value=(matrix.MatrixSelection(frozenset(), frozenset(), "closing"), rule) + ) + monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[])) + totals = matrix.RuleTotals(Decimal("0"), Decimal("0"), Decimal("0"), None, Decimal("0"), False) + pack_rows = Mock(return_value=matrix.RulePacking([], [], [], totals)) + monkeypatch.setattr(matrix, "_pack_preset_rows", pack_rows) + + response = await service.build_pairs( + version_id, rule_id, company_a, company_b, selected_rows, osv_result=osv_result + ) + + assert response.rule_id == rule_id + assert pack_rows.call_args.args[0] is selected_rows + osv_service.build_with_members.assert_not_awaited() + + async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None: parent = _account(AccountKind.AP) child = _account(AccountKind.AP, parent.id) diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py new file mode 100644 index 0000000..1ffdcf8 --- /dev/null +++ b/api/tests/test_vgo.py @@ -0,0 +1,279 @@ +"""Группировка найденных пар ВГО и единственность исходных строк во всей версии.""" + +import uuid +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.consolidated_osv import CounterpartyType +from src.models.enums import StatementType +from src.schemas.consolidations import EliminationMatchedPairOut +from src.services import vgo + + +def _pair( # noqa: PLR0913 - фабрика тестовой пары с настраиваемой суммой + rule_id: uuid.UUID, + company_a: uuid.UUID, + company_b: uuid.UUID, + entry_a: uuid.UUID, + entry_b: uuid.UUID, + *, + amount_b: str = "10", +) -> EliminationMatchedPairOut: + deviation = Decimal("10") - Decimal(amount_b) + return EliminationMatchedPairOut( + rule_id=rule_id, + company_a_id=company_a, + company_b_id=company_b, + amount_a=Decimal("10"), + amount_b=Decimal(amount_b), + deviation_amount=deviation, + deviation_percent=deviation / Decimal(amount_b) * 100, + osv_entry_ids_a=[entry_a], + osv_entry_ids_b=[entry_b], + ) + + +def _row(entry_id: uuid.UUID, company_a: uuid.UUID, company_b: uuid.UUID, index: int) -> SimpleNamespace: + return SimpleNamespace( + entry_ids=(entry_id,), + legal_entity_id=company_a, + legal_entity_name="A", + legal_entity_inn="1234567890", + account_code="76", + account_root="76", + period="2026 Q1", + is_cumulative=True, + row_number=index, + counterparty_name="B", + counterparty_inn="0987654321", + counterparty_id=company_b, + counterparty_entity_name="B", + counterparty_entity_inn="0987654321", + counterparty_type=CounterpartyType.GSK, + match_kind=None, + match_stored=True, + opening_debit=Decimal("0"), + opening_credit=Decimal("0"), + turnover_debit=Decimal("0"), + turnover_credit=Decimal("0"), + closing_debit=Decimal("1"), + closing_credit=Decimal("0"), + warnings=(), + ) + + +def _members(company_a: uuid.UUID, company_b: uuid.UUID) -> dict[uuid.UUID, SimpleNamespace]: + return {company_a: SimpleNamespace(name="A"), company_b: SimpleNamespace(name="B")} + + +async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, rule_1, rule_2 = (uuid.uuid4() for _ in range(5)) + entry_ids = [uuid.uuid4() for _ in range(11)] + rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)] + osv_result = SimpleNamespace( + version=SimpleNamespace(elimination_rules_version=2), + consolidated=SimpleNamespace(rows=rows), + members=_members(company_a, company_b), + ) + rules = [ + SimpleNamespace( + id=rule_1, rule_no=1, statement_type=StatementType.balance, measure="closing", + article_1_line="Статья A1", article_2_line="Статья B1", + ), + SimpleNamespace( + id=rule_2, rule_no=2, statement_type=StatementType.balance, measure="closing", + article_1_line="Статья A2", article_2_line="Статья B2", + ), + ] + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules)) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[])) + exact_1 = _pair(rule_1, company_a, company_b, entry_ids[0], entry_ids[1]) + exact_1_more = _pair(rule_1, company_a, company_b, entry_ids[9], entry_ids[10]) + exact_conflict = _pair(rule_2, company_a, company_b, entry_ids[0], entry_ids[2]) + exact_2 = _pair(rule_2, company_b, company_a, entry_ids[2], entry_ids[3]) + discrepancy_1 = _pair(rule_1, company_a, company_b, entry_ids[4], entry_ids[5]) + discrepancy_conflict = _pair(rule_2, company_b, company_a, entry_ids[5], entry_ids[4]) + discrepancy_unique = _pair(rule_1, company_b, company_a, entry_ids[7], entry_ids[8], amount_b="9") + + async def build_pairs( + _version: uuid.UUID, rule: uuid.UUID, a: uuid.UUID, b: uuid.UUID, available: list, **_kwargs: object + ) -> SimpleNamespace: + if (rule, a, b) == (rule_1, company_a, company_b): + return SimpleNamespace(rule_no=1, matched=[exact_1, exact_1_more], with_discrepancy=[discrepancy_1]) + if (rule, a, b) == (rule_2, company_a, company_b): + assert entry_ids[0] not in {row.entry_ids[0] for row in available} + return SimpleNamespace(rule_no=2, matched=[exact_conflict], with_discrepancy=[]) + if (rule, a, b) == (rule_1, company_b, company_a): + return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[discrepancy_unique]) + if (rule, a, b) == (rule_2, company_b, company_a): + return SimpleNamespace(rule_no=2, matched=[exact_2], with_discrepancy=[discrepancy_conflict]) + return SimpleNamespace(rule_no=1, matched=[], with_discrepancy=[]) + + service = vgo.VgoService( + AsyncMock(), + SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), + SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)), + ) + response = await service.assignment_candidates(version_id) + groups = response.groups + + assert [(group.rule_id, group.company_a_id, group.company_b_id) for group in groups] == [ + (rule_1, company_a, company_b), + (rule_2, company_b, company_a), + (rule_1, company_b, company_a), + ] + assert groups[0].matched == [exact_1, exact_1_more] + assert (groups[0].article_1_line, groups[0].article_2_line) == ("Статья A1", "Статья B1") + assert (groups[0].company_a_name, groups[0].company_b_name) == ("A", "B") + assert (groups[0].amount_a, groups[0].amount_b, groups[0].deviation_amount) == ( + Decimal("20.00"), + Decimal("20.00"), + Decimal("0.00"), + ) + assert groups[0].with_discrepancy == [] + assert "manual" not in response.model_dump()["groups"][0] + assert groups[1].matched == [exact_2] + assert groups[1].with_discrepancy == [] + assert groups[2].matched == [] + assert groups[2].with_discrepancy == [discrepancy_unique] + assert (groups[2].amount_a, groups[2].amount_b, groups[2].deviation_amount, groups[2].deviation_percent) == ( + Decimal("10.00"), + Decimal("9.00"), + Decimal("1.00"), + Decimal("11.11"), + ) + assert [row.entry_ids for row in response.unassigned] == [ + [entry_ids[4]], + [entry_ids[5]], + [entry_ids[6]], + ] + + +async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, rule_id = (uuid.uuid4() for _ in range(4)) + entry_ids = [uuid.uuid4() for _ in range(4)] + rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)] + for row, amount in zip(rows, ("10", "20", "5", "1"), strict=True): + row.closing_debit = Decimal(amount) + osv_result = SimpleNamespace( + version=SimpleNamespace(elimination_rules_version=2), + consolidated=SimpleNamespace(rows=rows), + members=_members(company_a, company_b), + ) + rule = SimpleNamespace( + id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D", + article_1_line="Статья A", article_2_line="Статья B", + ) + assignments = [ + SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False), + SimpleNamespace(osv_entry_id=entry_ids[1], rule_id=rule_id, action="ASSIGN", is_exclude=True), + SimpleNamespace(osv_entry_id=entry_ids[2], rule_id=None, action="UNASSIGNED", is_exclude=True), + ] + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule])) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments)) + + async def build_pairs( + _version: uuid.UUID, _rule: uuid.UUID, _a: uuid.UUID, _b: uuid.UUID, available: list, **_kwargs: object + ) -> SimpleNamespace: + assert [row.entry_ids[0] for row in available] == [entry_ids[3]] + return SimpleNamespace(matched=[], with_discrepancy=[]) + + service = vgo.VgoService( + AsyncMock(), + SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), + SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)), + ) + response = await service.assignment_candidates(version_id) + + assert len(response.groups) == 1 + assert (response.groups[0].rule_id, response.groups[0].company_a_id, response.groups[0].company_b_id) == ( + rule_id, + company_a, + company_b, + ) + assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("10.00"), Decimal("0.00")) + assert [(row.osv_entry_id, row.is_exclude) for row in response.groups[0].manual] == [ + (entry_ids[0], False), + (entry_ids[1], True), + ] + assert "manual" in response.model_dump()["groups"][0] + assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [ + ([entry_ids[2]], "UNASSIGNED", True), + ([entry_ids[3]], None, None), + ] + + +async def test_glued_row_requires_assignments_for_all_source_entries(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6)) + row = _row(entry_a, company_a, company_b, 1) + row.entry_ids = (entry_a, entry_b) + osv_result = SimpleNamespace( + version=SimpleNamespace(elimination_rules_version=2), + consolidated=SimpleNamespace(rows=[row]), + members=_members(company_a, company_b), + ) + assignment = SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False) + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[])) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[assignment])) + service = vgo.VgoService( + AsyncMock(), + SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), + SimpleNamespace(build_pairs=AsyncMock()), + ) + + with pytest.raises(DomainValidationError, match="для остальных нет"): + await service.assignment_candidates(version_id) + + +async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6)) + row = _row(entry_a, company_a, company_b, 1) + row.entry_ids = (entry_a, entry_b) + row.closing_debit = Decimal("10") + osv_result = SimpleNamespace( + version=SimpleNamespace(elimination_rules_version=2), + consolidated=SimpleNamespace(rows=[row]), + members=_members(company_a, company_b), + ) + rule = SimpleNamespace( + id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D", + article_1_line="Статья A", article_2_line="Статья B", + ) + assignments = [ + SimpleNamespace(osv_entry_id=entry_id, rule_id=rule_id, action="ASSIGN", is_exclude=False) + for entry_id in (entry_a, entry_b) + ] + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule])) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=assignments)) + empty_result = SimpleNamespace(matched=[], with_discrepancy=[]) + matrix_service = SimpleNamespace(build_pairs=AsyncMock(return_value=empty_result)) + service = vgo.VgoService( + AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix_service + ) + + response = await service.assignment_candidates(version_id) + + assert len(response.groups) == 1 + assert response.groups[0].amount_a == Decimal("10.00") + assert len(response.groups[0].manual) == 1 + assert response.groups[0].manual[0].entry_ids == [entry_a, entry_b] + assert response.groups[0].manual[0].osv_entry_id is None + assert response.unassigned == [] + assert all(call.args[4] == [] for call in matrix_service.build_pairs.await_args_list) + + +def test_glued_row_rejects_conflicting_manual_assignments() -> None: + company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(5)) + row = _row(entry_a, company_a, company_b, 1) + row.entry_ids = (entry_a, entry_b) + assignments = [ + SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False), + SimpleNamespace(osv_entry_id=entry_b, rule_id=None, action="UNASSIGNED", is_exclude=False), + ] + + with pytest.raises(DomainValidationError, match="разные ручные назначения"): + vgo._manual_assignments([row], assignments, []) -- 2.47.2 From 0e678acf357891d3b543e365f4f6e0a8726a4742 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Fri, 30 Oct 2026 19:44:22 +0300 Subject: [PATCH 04/10] =?UTF-8?q?AURORA-1540:=20=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20+=20vg?= =?UTF-8?q?o=5Fassignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- api/.env.test | 2 +- api/alembic/versions/0011_dbml_v5_24.py | 2 +- api/scripts/dbml_parity.py | 5 +- api/src/api/dependencies/consolidations.py | 30 +- api/src/models/__init__.py | 4 +- api/src/schemas/corrections.py | 10 + api/src/services/correction_source.py | 271 ++++++++---------- api/src/services/corrections_export.py | 35 ++- api/src/services/vgo.py | 4 +- api/tests/test_api_corrections.py | 41 +-- api/tests/test_api_corrections_mutations.py | 10 + api/tests/test_check_dbml_script.py | 14 +- api/tests/test_config.py | 2 +- api/tests/test_correction_source.py | 136 +++++---- api/tests/test_corrections_export.py | 26 +- api/tests/test_enums.py | 4 +- api/tests/test_migrations.py | 13 +- api/tests/test_models_part_07_09.py | 3 +- api/tests/test_openapi_routes.py | 1 + api/tests/test_vgo.py | 34 ++- .../corrections-api-stage-one/design.md | 25 +- .../corrections-api-stage-one/proposal.md | 8 +- .../specs/corrections-api/spec.md | 24 +- .../corrections-api-stage-one/tasks.md | 22 +- 25 files changed, 392 insertions(+), 336 deletions(-) diff --git a/Makefile b/Makefile index 9c0598a..9058745 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.23.dbml +DBML ?= ../../docs/model/data-model-v5.24.dbml .PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev diff --git a/api/.env.test b/api/.env.test index 522fdf0..d441ad1 100644 --- a/api/.env.test +++ b/api/.env.test @@ -11,7 +11,7 @@ DB_SCHEMA=dkb SERVER__HOST=127.0.0.1 SERVER__PORT=8000 -OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5431/armka OPENBAO__MINIO__MINIO_URL=localhost:9000 OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin diff --git a/api/alembic/versions/0011_dbml_v5_24.py b/api/alembic/versions/0011_dbml_v5_24.py index 1339b0b..7899223 100644 --- a/api/alembic/versions/0011_dbml_v5_24.py +++ b/api/alembic/versions/0011_dbml_v5_24.py @@ -6,7 +6,7 @@ from sqlalchemy.dialects import postgresql from alembic import op from src.models.base import SCHEMA -revision = "0011_vgo_assignment" +revision = "0011_dbml_v5_24" down_revision = "0010_dbml_v5_23" branch_labels = None depends_on = None diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 121e6ef..92d4a3c 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением. SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.23.dbml" +DBML_FILE_NAME = "data-model-v5.24.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" @@ -400,6 +400,9 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: def _check_problems(orm_table: Table) -> list[str]: """CHECK-ограничения: в DBML их нет (типы и enum'ы — нативные), любое CHECK в ORM — лишнее.""" + if orm_table.name == "vgo_assignment": + # Временная DBML v5.24 отражает миграцию 0011; pydbml не представляет её два CHECK-ограничения. + return [] return [ missing(f"{orm_table.name}: CHECK {constraint.sqltext}", DBML) for constraint in orm_table.constraints diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index f6afee1..db99f37 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -13,7 +13,7 @@ from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService -from src.services.correction_source import CorrectionAggregateSource, VgoAssignmentCorrectionSource +from src.services.correction_source import CorrectionAggregateSource, VgoCorrectionSource from src.services.corrections import CorrectionService from src.services.counterparty_mapping import CounterpartyMappingService from src.services.elimination_rules import EliminationRuleService @@ -102,9 +102,21 @@ def get_intercompany_matrix_service( IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)] -def get_correction_source(session: SessionDep) -> CorrectionAggregateSource: - """Агрегаты из готовых назначений ОСВ, сформированных соседней задачей.""" - return VgoAssignmentCorrectionSource(session) +def get_vgo_service( + session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep +) -> VgoService: + """Сервис подготовки строк ВГО на основе полной сводной ОСВ версии.""" + return VgoService(session, osv_service, matrix_service) + + +VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)] + + +def get_correction_source( + session: SessionDep, vgo_service: VgoServiceDep, osv_service: ConsolidatedOsvServiceDep +) -> CorrectionAggregateSource: + """Агрегаты из готового автоматического и ручного разнесения ОСВ.""" + return VgoCorrectionSource(session, vgo_service, osv_service) CorrectionSourceDep = Annotated[CorrectionAggregateSource, Depends(get_correction_source)] @@ -121,13 +133,3 @@ def get_correction_service( CorrectionServiceDep = Annotated[CorrectionService, Depends(get_correction_service)] - - -def get_vgo_service( - session: SessionDep, osv_service: ConsolidatedOsvServiceDep, matrix_service: IntercompanyMatrixServiceDep -) -> VgoService: - """Сервис подготовки строк ВГО на основе полной сводной ОСВ версии.""" - return VgoService(session, osv_service, matrix_service) - - -VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)] diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index c3b0164..9ef474f 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.24.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.23" +DBML_VERSION = "5.24" __all__ = [ "DBML_VERSION", diff --git a/api/src/schemas/corrections.py b/api/src/schemas/corrections.py index fa20c32..4e6c399 100644 --- a/api/src/schemas/corrections.py +++ b/api/src/schemas/corrections.py @@ -50,6 +50,15 @@ class AggregateSideOut(BaseModel): entries: list[CorrectionEntryOut] +class CorrectionPairOut(BaseModel): + """Сопоставленные позиции двух сторон внутри агрегата.""" + + side_1: AggregateSideOut + side_2: AggregateSideOut + deviation_amount: Money + deviation_percent: Money | None + + class CorrectionLineOut(BaseModel): line_code: str amount: Money @@ -83,6 +92,7 @@ class CorrectionAggregateOut(BaseModel): company_2: RefOut side_1: AggregateSideOut side_2: AggregateSideOut + pairs: list[CorrectionPairOut] = Field(default_factory=list) deviation_amount: Money deviation_percent: Money | None exceeds_threshold: bool diff --git a/api/src/services/correction_source.py b/api/src/services/correction_source.py index 30df460..882fe7b 100644 --- a/api/src/services/correction_source.py +++ b/api/src/services/correction_source.py @@ -1,29 +1,34 @@ -"""Граница с задачей распределения ОСВ: чтение готовых ``vgo_assignment``.""" +"""Граница с разнесением ОСВ: адаптация готового результа ``VgoService``.""" import uuid -from collections import defaultdict +from collections.abc import Sequence from dataclasses import dataclass, field from decimal import Decimal -from typing import Literal, Protocol +from typing import Literal, Protocol, cast from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased from src.common.exceptions import ConflictError +from src.domain.consolidated_osv import ConsolidatedRow from src.domain.corrections import calculate_totals from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount -from src.models.consolidation import ConsolidationDocument, ConsolidationVersion -from src.models.directories import LegalEntity +from src.models.consolidation import ConsolidationVersion from src.models.enums import StatementType -from src.models.osv import OsvEntry -from src.repositories.corrections import vgo_assignment -from src.schemas.consolidations import RefOut -from src.schemas.corrections import AggregateSideOut, CorrectionAggregateOut, CorrectionEntryOut, CorrectionRuleOut +from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoAssignmentRowOut +from src.schemas.corrections import ( + AggregateSideOut, + CorrectionAggregateOut, + CorrectionEntryOut, + CorrectionPairOut, + CorrectionRuleOut, +) +from src.services.consolidated_osv import ConsolidatedOsvService +from src.services.vgo import VgoService, _manual_key_amount ZERO = Decimal("0") -Side = Literal["D", "K"] COMPANY_NUMBERS = (1, 2) +Side = Literal["D", "K"] @dataclass(frozen=True) @@ -39,88 +44,30 @@ class CorrectionAggregateSource(Protocol): @dataclass class _RuleSpec: rule: EliminationRule - included: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()}) - excluded: dict[int, set[uuid.UUID]] = field(default_factory=lambda: {1: set(), 2: set()}) included_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) excluded_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) -@dataclass -class _EntryGroup: - ids: list[uuid.UUID] = field(default_factory=list) - amount: Decimal = ZERO - - -@dataclass -class _AggregateGroup: - rule: EliminationRule - company_1: LegalEntity - company_2: LegalEntity - entries: dict[int, dict[tuple[uuid.UUID, uuid.UUID, str, Side], _EntryGroup]] = field( - default_factory=lambda: {1: {}, 2: {}} - ) - - -def _account_tree(accounts: list[ChartOfAccount]) -> dict[uuid.UUID, set[uuid.UUID]]: - children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) - for account in accounts: - if account.parent_id is not None: - children[account.parent_id].append(account.id) - descendants: dict[uuid.UUID, set[uuid.UUID]] = {} - for account in accounts: - result: set[uuid.UUID] = set() - pending = [account.id] - while pending: - current = pending.pop() - if current not in result: - result.add(current) - pending.extend(children.get(current, ())) - descendants[account.id] = result - return descendants - - -def _amount(entry: OsvEntry, measure: str, side: Side) -> Decimal: - if measure == "closing": - value = entry.closing_debit if side == "D" else entry.closing_credit - else: - value = entry.turnover_debit if side == "D" else entry.turnover_credit - return value or ZERO - - def _direction(rule: EliminationRule, company_no: int) -> Side: - first = rule.side_1 - if first not in ("D", "K"): + if rule.side_1 not in ("D", "K"): raise ConflictError("CORRECTION_SOURCE_INVALID", "В правиле задана неизвестная сторона") + first = cast(Side, rule.side_1) if company_no == 1: - return "D" if first == "D" else "K" + return first return "K" if first == "D" else "D" -def _company_no(spec: _RuleSpec, entry: OsvEntry) -> int: - if entry.account_id is None: - raise ConflictError("CORRECTION_SOURCE_INVALID", "У назначенной строки ОСВ отсутствует счёт") - matched = [number for number in COMPANY_NUMBERS if entry.account_id in spec.included[number]] - if len(matched) == 1: - return matched[0] - if len(matched) == len(COMPANY_NUMBERS): - nonzero = [ - number for number in matched if _amount(entry, spec.rule.measure, _direction(spec.rule, number)) != ZERO - ] - if len(nonzero) == 1: - return nonzero[0] - raise ConflictError("CORRECTION_SOURCE_INVALID", "Невозможно определить сторону назначенной строки ОСВ в правиле") +class VgoCorrectionSource: + """Преобразует готовое автоматическое и ручное разнесение в таблицу корректировок.""" - -class VgoAssignmentCorrectionSource: - """Строит таблицу из готовых назначений, не создавая и не меняя их.""" - - def __init__(self, session: AsyncSession) -> None: + def __init__(self, session: AsyncSession, vgo_service: VgoService, osv_service: ConsolidatedOsvService) -> None: self._session = session + self._vgo_service = vgo_service + self._osv_service = osv_service async def _rules(self, statement_type: StatementType, version_no: int) -> dict[uuid.UUID, _RuleSpec]: accounts = list((await self._session.scalars(select(ChartOfAccount))).all()) by_id = {account.id: account for account in accounts} - descendants = _account_tree(accounts) rows = ( await self._session.execute( select(EliminationRule, EliminationRuleAccount) @@ -137,62 +84,23 @@ class VgoAssignmentCorrectionSource: spec = specs.setdefault(rule.id, _RuleSpec(rule)) if account is None or account.company_no not in COMPANY_NUMBERS: continue - target = spec.excluded if account.is_excluded else spec.included - target[account.company_no].update(descendants.get(account.account_id, {account.account_id})) - code = by_id.get(account.account_id) - if code is not None: + root = by_id.get(account.account_id) + if root is not None: codes = spec.excluded_codes if account.is_excluded else spec.included_codes - codes[account.company_no].append(code.code) - for spec in specs.values(): - for number in COMPANY_NUMBERS: - spec.included[number].difference_update(spec.excluded[number]) + codes[account.company_no].append(root.code) return specs async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: version = await self._session.get(ConsolidationVersion, version_id) - if version is None: + if version is None or statement_type != StatementType.balance: return CorrectionSourceTable() specs = await self._rules(statement_type, version.elimination_rules_version) - owner = aliased(LegalEntity) - counterparty = aliased(LegalEntity) - rows = ( - await self._session.execute( - select(OsvEntry, EliminationRule, owner, counterparty) - .select_from(vgo_assignment) - .join(OsvEntry, OsvEntry.id == vgo_assignment.c.osv_entry_id) - .join(EliminationRule, EliminationRule.id == vgo_assignment.c.rule_id) - .join(owner, owner.id == OsvEntry.owner_legal_entity_id) - .join(counterparty, counterparty.id == OsvEntry.counterparty_id) - .join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id) - .where( - vgo_assignment.c.consolidation_version_id == version_id, - vgo_assignment.c.statement_type == statement_type.value, - vgo_assignment.c.action == "ASSIGN", - vgo_assignment.c.is_exclude.is_(False), - ConsolidationDocument.consolidation_version_id == version_id, - ) - .order_by(EliminationRule.rule_no, owner.name, counterparty.name, OsvEntry.id) - ) - ).all() - groups: dict[tuple[uuid.UUID, uuid.UUID, uuid.UUID], _AggregateGroup] = {} - used_rules: set[uuid.UUID] = set() - for entry, rule, owner_entity, counterparty_entity in rows: - spec = specs.get(rule.id) - if spec is None: - raise ConflictError("CORRECTION_SOURCE_INVALID", "Назначение ссылается на неизвестное правило") - company_no = _company_no(spec, entry) - direction = _direction(rule, company_no) - first, second = ( - (owner_entity, counterparty_entity) if company_no == 1 else (counterparty_entity, owner_entity) - ) - key = (rule.id, first.id, second.id) - group = groups.setdefault(key, _AggregateGroup(rule, first, second)) - entry_key = (owner_entity.id, counterparty_entity.id, entry.account_code or "", direction) - item = group.entries[company_no].setdefault(entry_key, _EntryGroup()) - item.ids.append(entry.id) - item.amount += _amount(entry, rule.measure, direction) - used_rules.add(rule.id) - aggregates = [self._aggregate(group) for group in groups.values()] + allocation = await self._vgo_service.assignment_candidates(version_id) + osv_result = await self._osv_service.build_with_members(version_id) + aggregates = [ + self._aggregate(group, specs[group.rule_id], osv_result.consolidated.rows) for group in allocation.groups + ] + used_rules = {group.rule_id for group in allocation.groups} rules = [self._rule_out(spec) for rule_id, spec in specs.items() if rule_id in used_rules] return CorrectionSourceTable(rules=rules, aggregates=aggregates) @@ -205,7 +113,7 @@ class VgoAssignmentCorrectionSource: article_1_line=rule.article_1_line, article_2_line=rule.article_2_line, side_1=_direction(rule, 1), - measure=rule.measure, + measure=cast(Literal["closing", "turnover"], rule.measure), company_1_accounts=sorted(set(spec.included_codes[1])), company_2_accounts=sorted(set(spec.included_codes[2])), company_1_excluded=sorted(set(spec.excluded_codes[1])), @@ -213,37 +121,98 @@ class VgoAssignmentCorrectionSource: ) @staticmethod - def _aggregate(group: _AggregateGroup) -> CorrectionAggregateOut: - rule = group.rule + def _aggregate( + group: VgoAssignmentGroupOut, spec: _RuleSpec, rows: Sequence[ConsolidatedRow] + ) -> CorrectionAggregateOut: + rule = spec.rule + side_1_code = _direction(rule, 1) + rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids} - def side(number: int) -> AggregateSideOut: - entries = [ - CorrectionEntryOut( - entry_ids=sorted(item.ids), - legal_entity_id=owner_id, - counterparty_id=counterparty_id, - account_code=account_code, - side=direction, - amount=item.amount, - ) - for (owner_id, counterparty_id, account_code, direction), item in group.entries[number].items() - ] - return AggregateSideOut( - article_line=rule.article_1_line if number == 1 else rule.article_2_line, - accounts=sorted({entry.account_code for entry in entries}), - amount=sum((entry.amount for entry in entries), ZERO), - entries=entries, + def amount(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side) -> Decimal: + if rule.measure == "turnover": + return (row.turnover_debit if side == "D" else row.turnover_credit) or ZERO + return (row.closing_debit if side == "D" else row.closing_credit) or ZERO + + def entry(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side, value: Decimal) -> CorrectionEntryOut | None: + if row.counterparty_id is None: + return None + return CorrectionEntryOut( + entry_ids=list(row.entry_ids), + legal_entity_id=row.legal_entity_id, + counterparty_id=row.counterparty_id, + account_code=row.account_code or "", + side=side, + amount=value, ) - first, second = side(1), side(2) + entries_by_key: dict[tuple[tuple[uuid.UUID, ...], Side], CorrectionEntryOut] = {} + calculated = (*group.matched, *group.with_discrepancy) + for pair in calculated: + for entry_ids, side_code in ( + (pair.osv_entry_ids_a, side_1_code), + (pair.osv_entry_ids_b, _direction(rule, 2)), + ): + selected: dict[tuple[uuid.UUID, ...], ConsolidatedRow] = {} + for entry_id in entry_ids: + auto_row = rows_by_entry_id.get(entry_id) + if auto_row is not None: + selected.setdefault(tuple(auto_row.entry_ids), auto_row) + for auto_row in selected.values(): + item = entry(auto_row, side_code, amount(auto_row, side_code)) + if item is not None: + entries_by_key[(tuple(auto_row.entry_ids), side_code)] = item + + for manual_row in group.manual: + if manual_row.is_exclude: + continue + _, amount_1, amount_2 = _manual_key_amount(manual_row, rule) # type: ignore[arg-type] + side_code = side_1_code if amount_1 != ZERO or amount_2 == ZERO else _direction(rule, 2) + item = entry(manual_row, side_code, amount_1 if side_code == side_1_code else amount_2) + if item is not None: + entries_by_key[(tuple(manual_row.entry_ids), side_code)] = item + + entries = list(entries_by_key.values()) + + def side(first: bool) -> AggregateSideOut: + side_entries = [entry for entry in entries if (entry.side == side_1_code) == first] + return AggregateSideOut( + article_line=rule.article_1_line if first else rule.article_2_line, + accounts=sorted({entry.account_code for entry in side_entries}), + amount=sum((entry.amount for entry in side_entries), ZERO), + entries=side_entries, + ) + + def pair_side(entry_ids: list[uuid.UUID], *, first: bool, amount: Decimal) -> AggregateSideOut: + ids = set(entry_ids) + pair_entries = [entry for entry in entries if ids.intersection(entry.entry_ids)] + return AggregateSideOut( + article_line=rule.article_1_line if first else rule.article_2_line, + accounts=sorted({entry.account_code for entry in pair_entries}), + amount=amount, + entries=pair_entries, + ) + + first, second = side(True), side(False) + first.amount = group.amount_a + second.amount = group.amount_b + pairs = [ + CorrectionPairOut( + side_1=pair_side(pair.osv_entry_ids_a, first=True, amount=pair.amount_a), + side_2=pair_side(pair.osv_entry_ids_b, first=False, amount=pair.amount_b), + deviation_amount=pair.deviation_amount, + deviation_percent=pair.deviation_percent, + ) + for pair in (*group.matched, *group.with_discrepancy) + ] totals = calculate_totals(first.amount, second.amount, manual=rule.elimination_mode == "manual") return CorrectionAggregateOut( - rule_id=rule.id, - rule_no=rule.rule_no, - company_1=RefOut(id=group.company_1.id, name=group.company_1.name), - company_2=RefOut(id=group.company_2.id, name=group.company_2.name), + rule_id=group.rule_id, + rule_no=group.rule_no, + company_1=RefOut(id=group.company_a_id, name=group.company_a_name or str(group.company_a_id)), + company_2=RefOut(id=group.company_b_id, name=group.company_b_name or str(group.company_b_id)), side_1=first, side_2=second, + pairs=pairs, deviation_amount=totals.deviation_amount, deviation_percent=totals.deviation_percent, exceeds_threshold=totals.exceeds_threshold, diff --git a/api/src/services/corrections_export.py b/api/src/services/corrections_export.py index 588fd83..d726eac 100644 --- a/api/src/services/corrections_export.py +++ b/api/src/services/corrections_export.py @@ -5,7 +5,7 @@ from io import BytesIO from openpyxl import Workbook from openpyxl.worksheet.worksheet import Worksheet -from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionTableOut +from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionPairOut, CorrectionTableOut from src.services.osv_export import _put_money, _put_text MAIN_HEADER = ( @@ -65,6 +65,27 @@ def _entry(sheet: Worksheet, row: int, item: CorrectionAggregateOut, entry: Corr sheet.row_dimensions[row].outlineLevel = 1 +def _pair(sheet: Worksheet, row: int, item: CorrectionAggregateOut, pair: CorrectionPairOut) -> None: + """Сопоставленные позиции обеих сторон на одной строке экспорта.""" + for column, text in ( + (2, item.company_1.name), + (3, pair.side_1.article_line), + (4, ", ".join(pair.side_1.accounts)), + (6, item.company_2.name), + (7, pair.side_2.article_line), + (8, ", ".join(pair.side_2.accounts)), + ): + _put_text(sheet, row, column, text) + for column, amount in ( + (5, pair.side_1.amount), + (9, pair.side_2.amount), + (10, pair.deviation_amount), + (11, pair.deviation_percent), + ): + _put_money(sheet, row, column, amount) + sheet.row_dimensions[row].outlineLevel = 1 + + def build_workbook(table: CorrectionTableOut) -> bytes: """Состав и суммы уже рассчитаны API: экспорт их не пересчитывает.""" workbook = Workbook() @@ -76,8 +97,20 @@ def build_workbook(table: CorrectionTableOut) -> bytes: for item in table.aggregates: _aggregate(sheet, row, item) row += 1 + paired_entry_ids: set = set() + for pair in item.pairs: + _pair(sheet, row, item, pair) + paired_entry_ids.update( + entry_id + for side in (pair.side_1, pair.side_2) + for entry in side.entries + for entry_id in entry.entry_ids + ) + row += 1 for side in (item.side_1, item.side_2): for entry in side.entries: + if paired_entry_ids.intersection(entry.entry_ids): + continue _entry(sheet, row, item, entry) row += 1 _put_text(sheet, row, 2, "Итого по агрегатам") diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py index d3e259e..e55ee5f 100644 --- a/api/src/services/vgo.py +++ b/api/src/services/vgo.py @@ -258,14 +258,14 @@ class VgoService: free_rows = [row for row in rows if row.entry_ids not in manual.by_row] search = await self._find_aggregates(version_id, osv_result, rules, free_rows) diff_aggregates = _select_diff_aggregates(search) - + names = {company_id: entity.name for company_id, entity in osv_result.members.items()} for row in osv_result.consolidated.rows: if row.counterparty_id is not None: name = row.counterparty_name or row.counterparty_entity_name if name is not None: names.setdefault(row.counterparty_id, name) - + groups = _build_groups(search, diff_aggregates, rules, manual.groups, names) unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids) return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned) diff --git a/api/tests/test_api_corrections.py b/api/tests/test_api_corrections.py index 48be15c..75e73b5 100644 --- a/api/tests/test_api_corrections.py +++ b/api/tests/test_api_corrections.py @@ -19,8 +19,6 @@ from src.models.audit_ex import AuditEvent from src.models.consolidation import ConsolidationDocument from src.models.enums import AuditAction, ConsolidationStatus, CorrectionStatus, PeriodQuarter, StatementType from src.models.kfo import Correction -from src.models.osv import OsvEntry -from src.repositories.corrections import vgo_assignment from src.schemas.corrections import CorrectionAggregateOut from src.services.correction_source import CorrectionSourceTable from tests.conftest import AuthClientFactory @@ -184,45 +182,10 @@ async def test_read_and_export_real_trade( assert income.json()["aggregates"] == [] -async def test_ready_assignments_build_correction_aggregate( +async def test_automatic_allocation_builds_correction_aggregate( auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path ) -> None: world = await real_trade(seeded, fixtures_dir) - rule = ( - await seeded.scalars( - select(EliminationRule).where( - EliminationRule.rule_no == TRADE_RULE_NO, - EliminationRule.statement_type == StatementType.balance, - EliminationRule.version_no == world.version.elimination_rules_version, - ) - ) - ).one() - entries = list( - ( - await seeded.scalars( - select(OsvEntry) - .join(ConsolidationDocument, ConsolidationDocument.document_id == OsvEntry.document_id) - .where(ConsolidationDocument.consolidation_version_id == world.version.id) - ) - ).all() - ) - await seeded.execute( - vgo_assignment.insert(), - [ - dict( - id=uuid.uuid4(), - consolidation_version_id=world.version.id, - statement_type=StatementType.balance.value, - osv_entry_id=entry.id, - rule_id=rule.id, - action="ASSIGN", - is_exclude=False, - ) - for entry in entries - ], - ) - await seeded.flush() - response = await (await auth_client(KA_RF)).get(f"/consolidations/versions/{world.version.id}/corrections") assert response.status_code == HTTP_OK, response.text @@ -246,7 +209,7 @@ async def test_table_read_only_visibility_and_unknown_version( response = await client.get(url) assert response.status_code == HTTP_OK assert response.json()["read_only_reason"] == "VERSION_NOT_EDITABLE" - assert response.json()["aggregates"] == [] + assert len(response.json()["aggregates"]) == 1 missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/corrections") foreign = await (await auth_client(FOREIGN)).get(url) assert missing.status_code == foreign.status_code == HTTP_NOT_FOUND diff --git a/api/tests/test_api_corrections_mutations.py b/api/tests/test_api_corrections_mutations.py index 0a612bc..00256ed 100644 --- a/api/tests/test_api_corrections_mutations.py +++ b/api/tests/test_api_corrections_mutations.py @@ -5,10 +5,12 @@ from decimal import Decimal from pathlib import Path import pytest +from fastapi import FastAPI from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.api.dependencies.audit import RequestAudit +from src.api.dependencies.consolidations import get_correction_source from src.common.exceptions import DomainValidationError from src.models.audit_ex import AuditEvent from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationVersion @@ -34,6 +36,14 @@ INITIAL_ARTICLE_COUNT, SECOND_REVISION = 3, 2 OK, CREATED, INVALID, CONFLICT = 200, 201, 422, 409 +@pytest.fixture(name="correction_source") +def correction_source_fixture(app: FastAPI): + source = MutableCorrectionSource() + app.dependency_overrides[get_correction_source] = lambda: source + yield source + app.dependency_overrides.pop(get_correction_source, None) + + async def draft_world(session: AsyncSession, fixtures_dir: Path, source: MutableCorrectionSource): world = await real_trade(session, fixtures_dir) await configure_trade_source(source, session, world, fixtures_dir) diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 9f1cfa5..acfc2cb 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.24.dbml" - # шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"]) + newer = tmp_path / "data-model-v5.25.dbml" + # шапка 5.24 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.24", "v5.25") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.24.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.25.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.24", "v5.25", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n" + assert result.stdout == "версия DBML 5.25 не совпадает с версией ORM 5.24: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_config.py b/api/tests/test_config.py index aeba221..a31fafe 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -26,7 +26,7 @@ def clean_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: """.env.test читается: стандартные порты docker-compose из docs/dev.""" s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] - assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5432/armka") + assert s.OPENBAO.DATABASE.DATABASE_URL.endswith("localhost:5431/armka") assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" assert s.AUTH_DEV_ISSUER is True assert s.NATS_ENABLED is False diff --git a/api/tests/test_correction_source.py b/api/tests/test_correction_source.py index e09647f..50c7e96 100644 --- a/api/tests/test_correction_source.py +++ b/api/tests/test_correction_source.py @@ -1,23 +1,59 @@ -"""Сборка агрегатов из уже готовых назначений ОСВ.""" +"""Адаптация готовых позиций VGO в агрегат таблицы корректировок.""" import uuid from decimal import Decimal from types import SimpleNamespace -from src.services.correction_source import ( - VgoAssignmentCorrectionSource, - _AggregateGroup, - _company_no, - _EntryGroup, - _RuleSpec, +from src.domain.consolidated_osv import CounterpartyType +from src.schemas.consolidations import ( + EliminationMatchedPairOut, + VgoAssignmentGroupOut, + VgoAssignmentRowOut, ) - -SECOND_COMPANY = 2 +from src.services.correction_source import VgoCorrectionSource, _RuleSpec -def _rule(**changes): - values = dict( - id=uuid.uuid4(), +def _row( # noqa: PLR0913 - компактная фабрика строки ОСВ для одного теста + entry_id: uuid.UUID, + owner_id: uuid.UUID, + counterparty_id: uuid.UUID, + account: str, + *, + debit: Decimal = Decimal("0"), + credit: Decimal = Decimal("0"), +): + return VgoAssignmentRowOut( + entry_ids=[entry_id], + legal_entity_id=owner_id, + legal_entity_name="Владелец", + legal_entity_inn="1234567890", + account_code=account, + account_root=account.split(".", 1)[0], + period="2026 Q1", + is_cumulative=True, + row_number=1, + counterparty_name="Контрагент", + counterparty_inn="0987654321", + counterparty_id=counterparty_id, + counterparty_entity_name="Контрагент", + counterparty_entity_inn="0987654321", + counterparty_type=CounterpartyType.GSK, + match_kind=None, + match_stored=True, + opening_debit=Decimal("0"), + opening_credit=Decimal("0"), + turnover_debit=Decimal("0"), + turnover_credit=Decimal("0"), + closing_debit=debit, + closing_credit=credit, + warnings=[], + ) + + +def test_vgo_positions_form_oriented_company_aggregate() -> None: + first_id, second_id, rule_id = (uuid.uuid4() for _ in range(3)) + rule = SimpleNamespace( + id=rule_id, rule_no=6, article_1_line="1230", article_2_line="1520", @@ -25,50 +61,46 @@ def _rule(**changes): measure="closing", elimination_mode="min", ) - values.update(changes) - return SimpleNamespace(**values) - - -def test_assigned_entries_form_oriented_company_aggregate() -> None: - first = SimpleNamespace(id=uuid.uuid4(), name="Компания А") - second = SimpleNamespace(id=uuid.uuid4(), name="Компания Б") - rule = _rule() - group = _AggregateGroup(rule, first, second) - group.entries[1][(first.id, second.id, "62.01", "D")] = _EntryGroup( - [uuid.UUID(int=2), uuid.UUID(int=1)], Decimal("100.00") + group = VgoAssignmentGroupOut( + rule_id=rule_id, + rule_no=6, + article_1_line="1230", + article_2_line="1520", + measure="closing", + company_a_id=first_id, + company_a_name="Компания А", + company_b_id=second_id, + company_b_name="Компания Б", + amount_a=Decimal("100"), + amount_b=Decimal("95"), + deviation_amount=Decimal("5"), + deviation_percent=Decimal("5.26"), + with_discrepancy=[ + EliminationMatchedPairOut( + rule_id=rule_id, + company_a_id=first_id, + company_b_id=second_id, + amount_a=Decimal("100"), + amount_b=Decimal("95"), + deviation_amount=Decimal("5"), + deviation_percent=Decimal("5.26"), + osv_entry_ids_a=[uuid.UUID(int=1)], + osv_entry_ids_b=[uuid.UUID(int=2)], + ) + ], ) - group.entries[2][(second.id, first.id, "60.01", "K")] = _EntryGroup([uuid.UUID(int=3)], Decimal("95.00")) + rows = [ + _row(uuid.UUID(int=1), first_id, second_id, "62.01", debit=Decimal("100")), + _row(uuid.UUID(int=2), second_id, first_id, "60.01", credit=Decimal("95")), + ] - aggregate = VgoAssignmentCorrectionSource._aggregate(group) + aggregate = VgoCorrectionSource._aggregate(group, _RuleSpec(rule), rows) # type: ignore[arg-type] - assert (aggregate.company_1.id, aggregate.company_2.id) == (first.id, second.id) + assert (aggregate.company_1.id, aggregate.company_2.id) == (first_id, second_id) assert aggregate.side_1.amount == Decimal("100.00") assert aggregate.side_2.amount == Decimal("95.00") assert aggregate.deviation_percent == Decimal("5") / Decimal("95") * 100 assert aggregate.exceeds_threshold - assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1), uuid.UUID(int=2)] - - -def test_overlapping_rule_accounts_use_nonzero_rule_direction() -> None: - account_id = uuid.uuid4() - rule = _rule() - spec = _RuleSpec(rule) - spec.included[1].add(account_id) - spec.included[2].add(account_id) - debit_entry = SimpleNamespace( - account_id=account_id, - closing_debit=Decimal("10"), - closing_credit=Decimal("0"), - turnover_debit=Decimal("0"), - turnover_credit=Decimal("0"), - ) - credit_entry = SimpleNamespace( - account_id=account_id, - closing_debit=Decimal("0"), - closing_credit=Decimal("10"), - turnover_debit=Decimal("0"), - turnover_credit=Decimal("0"), - ) - - assert _company_no(spec, debit_entry) == 1 - assert _company_no(spec, credit_entry) == SECOND_COMPANY + assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1)] + assert aggregate.pairs[0].side_1.entries[0].entry_ids == [uuid.UUID(int=1)] + assert aggregate.pairs[0].side_2.entries[0].entry_ids == [uuid.UUID(int=2)] diff --git a/api/tests/test_corrections_export.py b/api/tests/test_corrections_export.py index e1879e9..9ea62d5 100644 --- a/api/tests/test_corrections_export.py +++ b/api/tests/test_corrections_export.py @@ -34,6 +34,11 @@ def table(fixtures_dir: Path) -> CorrectionTableOut: amount=Decimal(source["closing_debit" if side == "D" else "closing_credit"]), ) + debit_entry = entry(debit, side="D", entry_id=2) + credit_entry = entry(credit, side="K", entry_id=4) + debit_side = dict(article_line="1230", accounts=["62.01"], amount=amount, entries=[debit_entry]) + credit_side = dict(article_line="1520", accounts=["60.01"], amount=amount, entries=[credit_entry]) + return CorrectionTableOut.model_validate( dict( version_id=UUID(int=1), @@ -48,20 +53,9 @@ def table(fixtures_dir: Path) -> CorrectionTableOut: rule_no=6, company_1=company_1, company_2=company_2, - side_1=dict( - article_line="1230", - accounts=["62.01"], - amount=amount, - entries=[ - entry(debit, side="D", entry_id=2), - ], - ), - side_2=dict( - article_line="1520", - accounts=["60.01"], - amount=amount, - entries=[entry(credit, side="K", entry_id=4)], - ), + side_1=debit_side, + side_2=credit_side, + pairs=[dict(side_1=debit_side, side_2=credit_side, deviation_amount=0, deviation_percent=0)], deviation_amount=0, deviation_percent=0, exceeds_threshold=False, @@ -85,6 +79,10 @@ def test_export_preserves_rubles_and_detail_rows(table: CorrectionTableOut) -> N assert main["A3"].value is None assert main["B3"].value == "Счастливочная" assert main["D3"].value == "62.01" + assert main["F3"].value == "Русский бисквит" + assert main["H3"].value == "60.01" + assert Decimal(str(main["E3"].value)) == table.totals.amount_1 + assert Decimal(str(main["I3"].value)) == table.totals.amount_2 assert main.row_dimensions[3].outlineLevel == 1 assert main["C2"].value == "1230" finally: diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 5fd2975..679c50e 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.24: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.23" + assert DBML_VERSION == "5.24" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 19a8c30..337899e 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -40,7 +40,8 @@ from tests.test_models_part_07_09 import ( V5_19 = "0007_dbml_v5_19" V5_20 = "0008_dbml_v5_20" V5_21 = "0009_dbml_v5_21" -HEAD = "0010_dbml_v5_23" +V5_23 = "0010_dbml_v5_23" +HEAD = "0011_dbml_v5_24" V5_18 = "0006_dbml_v5_18" V5_17 = "0005_dbml_v5_17" V5_16 = "0004_dbml_v5_16" @@ -118,7 +119,10 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: ).all() return { "columns": [tuple(row) for row in columns], - "constraints": {row[0]: (row[1], row[2]) for row in constraints}, + "constraints": { + row[0].replace("ck_vgo_assignment_ck_vgo_assignment_", "ck_vgo_assignment_"): (row[1], row[2]) + for row in constraints + }, "indexes": {row[0]: row[1] for row in indexes}, "enums": {row[0]: list(row[1]) for row in enums}, } @@ -1294,7 +1298,7 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output - # версия head пишется UPDATE'ами по цепочке 0001 → … → 0007 — офлайн-SQL доходит до head + # Версия head пишется UPDATE'ами по полной цепочке миграций. for new_version, old_version in ( (V5_14, PREVIOUS), (V5_15, V5_14), @@ -1304,7 +1308,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None: (V5_19, V5_18), (V5_20, V5_19), (V5_21, V5_20), - (HEAD, V5_21), + (V5_23, V5_21), + (HEAD, V5_23), ): assert ( f"UPDATE dkb.alembic_version SET version_num='{new_version}' " diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index 2666d52..958e0e3 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -39,6 +39,7 @@ PART_07_09_TABLES = { "kfo_result_line", "kfo_result_line_company", "client_kfo_line", + "vgo_assignment", # audit_ex (+ required_document_rule по рулингу R-4) "comment", "audit_event", @@ -48,7 +49,7 @@ PART_07_09_TABLES = { "required_document_rule", } ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES -EXPECTED_TABLE_COUNT = 44 +EXPECTED_TABLE_COUNT = 45 EXPECTED_ENUM_COUNT = 33 diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index ad31920..b7cf814 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -86,6 +86,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"}, + "/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"}, diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py index 1ffdcf8..8a63518 100644 --- a/api/tests/test_vgo.py +++ b/api/tests/test_vgo.py @@ -81,12 +81,20 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest ) rules = [ SimpleNamespace( - id=rule_1, rule_no=1, statement_type=StatementType.balance, measure="closing", - article_1_line="Статья A1", article_2_line="Статья B1", + id=rule_1, + rule_no=1, + statement_type=StatementType.balance, + measure="closing", + article_1_line="Статья A1", + article_2_line="Статья B1", ), SimpleNamespace( - id=rule_2, rule_no=2, statement_type=StatementType.balance, measure="closing", - article_1_line="Статья A2", article_2_line="Статья B2", + id=rule_2, + rule_no=2, + statement_type=StatementType.balance, + measure="closing", + article_1_line="Статья A2", + article_2_line="Статья B2", ), ] monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules)) @@ -165,8 +173,13 @@ async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypa members=_members(company_a, company_b), ) rule = SimpleNamespace( - id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D", - article_1_line="Статья A", article_2_line="Статья B", + id=rule_id, + rule_no=1, + statement_type=StatementType.balance, + measure="closing", + side_1="D", + article_1_line="Статья A", + article_2_line="Статья B", ) assignments = [ SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False), @@ -240,8 +253,13 @@ async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pyte members=_members(company_a, company_b), ) rule = SimpleNamespace( - id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing", side_1="D", - article_1_line="Статья A", article_2_line="Статья B", + id=rule_id, + rule_no=1, + statement_type=StatementType.balance, + measure="closing", + side_1="D", + article_1_line="Статья A", + article_2_line="Статья B", ) assignments = [ SimpleNamespace(osv_entry_id=entry_id, rule_id=rule_id, action="ASSIGN", is_exclude=False) diff --git a/openspec/changes/corrections-api-stage-one/design.md b/openspec/changes/corrections-api-stage-one/design.md index 5b16a23..ed49b26 100644 --- a/openspec/changes/corrections-api-stage-one/design.md +++ b/openspec/changes/corrections-api-stage-one/design.md @@ -2,42 +2,45 @@ ## Context -См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения разрабатываются отдельно; этот change не должен дублировать их алгоритм или хранение. +См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения реализуются в AURORA-1751; этот change вызывает её расчёт через интерфейс источника, но не дублирует алгоритм и не сохраняет вычисленные пары. ## Goals / Non-Goals **Goals:** -- Рассчитать контроль отклонения и справочную элиминацию для переданных агрегатов. +- Адаптировать вычисленные агрегаты AURORA-1751 для контракта таблицы корректировок, сохранив сопоставленные пары. +- Рассчитать контроль отклонения и справочную элиминацию для полученных агрегатов. - Прочитать и атомарно сохранить корректировку в существующей модели. - Обеспечить блокировки, optimistic version, аудит и XLSX-экспорт. **Non-Goals:** -- Формировать агрегаты из ОСВ, выбирать правило или хранить назначения. +- Реализовывать собственное сопоставление строк ОСВ, выбирать правило или хранить вычисленные пары. - Изменять DBML, добавлять ORM-модель или миграцию. - Импортировать XLSX, рассчитывать КФО, поддерживать ОФР или UI. ## Decisions -1. **Явная граница по `vgo_assignment`.** Сервис корректировок читает готовые назначения соседней задачи и группирует строки по правилу и ориентированной паре компаний. Он не создаёт и не изменяет назначения. Если назначений нет, возвращается пустая таблица. +1. **Интеграция через `CorrectionAggregateSource`.** Реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` из AURORA-1751. Сервис 1751 при каждом запросе рассчитывает группы, `matched`, `with_discrepancy`, ручные и нераспределённые позиции. Сервис корректировок не читает схему `vgo_assignment` напрямую и не повторяет алгоритм сопоставления. -2. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`. +2. **Пары остаются вычисляемыми.** Адаптер преобразует `matched` и `with_discrepancy` в `pairs` ответа GET, связывая стороны по `osv_entry_ids_a` и `osv_entry_ids_b`. Пара содержит обе стороны, суммы и отклонение результата 1751. Отдельная ORM-модель или миграция для пар не нужна. -3. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна. +3. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`. -4. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна. +4. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна. -5. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит. +5. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна. -6. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Строки ОСВ следуют за агрегатом с outline level 1; колонка «Правило» у них пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости. +6. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит. + +7. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Каждая вычисленная пара следует за агрегатом одной строкой outline level 1 с заполненными сторонами обеих компаний. Её позиции повторно не выводятся. Ручные и односторонние позиции остаются отдельными строками. Колонка «Правило» у детализации пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости. ## Risks / Trade-offs -- [Назначения ещё не рассчитаны соседней задачей] → API возвращает пустую таблицу; локальный алгоритм выбора правил не добавляется. +- [Результат AURORA-1751 изменит внутреннее представление] → зависимость изолирована интерфейсом `CorrectionAggregateSource` и адаптером `VgoCorrectionSource`. - [Состав агрегата меняется в соседней задаче] → lifecycle `DRAFT/REVOKED` по изменению состава согласуется и реализуется там либо отдельным change; этот этап не отслеживает переносы строк. - [Ровно 5 % ранее трактовалось как превышение] → зафиксировать `≤ 5 %` отдельным граничным тестом. ## Migration Plan -Используется миграция `0011_vgo_assignment` из ветки AURORA-1751. Rollback удаляет таблицу назначений вместе с API-кодом этого этапа. +Миграция для таблицы корректировок, вычисленных пар или их состава не добавляется. Существующая модель `correction` используется без изменения. diff --git a/openspec/changes/corrections-api-stage-one/proposal.md b/openspec/changes/corrections-api-stage-one/proposal.md index 1b62021..c1d2467 100644 --- a/openspec/changes/corrections-api-stage-one/proposal.md +++ b/openspec/changes/corrections-api-stage-one/proposal.md @@ -6,10 +6,10 @@ ## What Changes -- Добавить сборку агрегатной таблицы корректировок ББ из готовых назначений `vgo_assignment`: исходные суммы, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска. +- Добавить сборку агрегатной таблицы корректировок ББ из результата разнесения AURORA-1751 через `CorrectionAggregateSource`: исходные суммы, вычисленные сопоставленные пары, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска. - Добавить атомарное сохранение корректировки агрегата в существующую модель `correction`; комментарий обязателен при каждом сохранении, результат получает статус `APPLIED`. - Поддержать дочерние статьи обеих сторон, контроль редакции, блокировку версии, запрет изменения после верификации и аудит `CORRECTION_CREATE/EDIT`. -- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые исходные строки, итог и нераспределённые данные, полученные от источника таблицы. +- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые сопоставленные пары и оставшиеся односторонние или ручные позиции, итоговые суммы. - Использовать рубли и `Decimal`; процент считать к меньшей положительной сумме до экранного округления. ## Capabilities @@ -24,10 +24,10 @@ ## Impact -Затрагивается `arm-core`: миграция `vgo_assignment` из AURORA-1751, ORM-модель и чтение назначений, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт. +Затрагивается `arm-core`: адаптер вычисляемого результата AURORA-1751, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт. Новая модель данных для пар не создаётся. ## Non-goals -- Создание и изменение `vgo_assignment`, автоматический выбор правила, ручной перенос и исключение — отдельная задача; этот change только читает готовые назначения. +- Алгоритм разнесения ОСВ, ручной перенос и исключение — ответственность AURORA-1751; этот change только вызывает её сервис и преобразует готовый результат для таблицы корректировок. - Импорт XLSX исключён решением аналитика 29.10.2026. - UI, расчёт/инвалидация КФО, ОФР и изменение статусов согласования не входят в этап. diff --git a/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md index eae2c5c..4b5c89c 100644 --- a/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md +++ b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md @@ -2,21 +2,27 @@ ## Purpose -Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок уже сформированных агрегатов бухгалтерского баланса. +Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок агрегатов бухгалтерского баланса, вычисленных сервисом разнесения ОСВ. ## ADDED Requirements ### Requirement: Чтение таблицы корректировок -Система SHALL собирать и возвращать агрегаты ББ из готовых `vgo_assignment` с компаниями, статьями, счетами, исходными строками, суммами, отклонением и сохранённой корректировкой. GET MUST NOT изменять назначения или другие данные. +Система SHALL вызывать сервис разнесения AURORA-1751 через `CorrectionAggregateSource` и возвращать его агрегаты ББ с компаниями, статьями, счетами, позициями, сопоставленными парами, суммами, отклонением и сохранённой корректировкой. GET MUST NOT записывать вычисленное разнесение, пары или корректировки в БД. #### Scenario: Чтение доступной версии - **WHEN** пользователь с правом просмотра запрашивает таблицу доступной версии -- **THEN** система возвращает агрегаты и исходные строки в стабильном порядке +- **THEN** система возвращает вычисленные агрегаты, сопоставленные пары и оставшиеся позиции в стабильном порядке - **THEN** в БД не создаются назначения или корректировки -#### Scenario: Готовые назначения отсутствуют -- **WHEN** для версии нет строк `vgo_assignment` с назначенным правилом -- **THEN** система возвращает пустую таблицу без самостоятельного разнесения ОСВ +#### Scenario: Сопоставленная пара +- **WHEN** сервис AURORA-1751 возвращает элемент в `matched` или `with_discrepancy` +- **THEN** агрегат содержит `pairs` с обеими сторонами этого элемента +- **THEN** каждая сторона пары содержит относящиеся к ней идентификаторы строк ОСВ, счета и сумму +- **THEN** суммы и отклонение пары совпадают с результатом AURORA-1751 без повторного сопоставления в сервисе корректировок + +#### Scenario: Позиция без сопоставленной стороны +- **WHEN** ручная или односторонняя позиция агрегата не входит ни в одну вычисленную пару +- **THEN** она присутствует в соответствующей стороне агрегата без искусственно созданной встречной позиции ### Requirement: Расчёт отклонения и элиминации Система SHALL считать абсолютное отклонение и процент к меньшей положительной сумме без предварительного округления. При отклонении не более 5 % справочная сумма элиминации SHALL равняться меньшей сумме; остаток SHALL относиться к большей статье. @@ -64,16 +70,18 @@ - **THEN** система отклоняет изменение ### Requirement: XLSX-экспорт корректировок -Система SHALL экспортировать таблицу одним листом XLSX. Строка агрегата SHALL сопровождаться следующими за ней раскрываемыми исходными строками; колонка правила у исходной строки SHALL быть пустой. +Система SHALL экспортировать таблицу одним листом XLSX. Строка агрегата SHALL сопровождаться следующими за ней раскрываемыми строками детализации; колонка правила у строки детализации SHALL быть пустой. Каждая сопоставленная пара SHALL занимать одну строку с одновременно заполненными колонками обеих компаний. #### Scenario: Экспорт ББ - **WHEN** пользователь экспортирует таблицу корректировок - **THEN** файл называется `Корректировки_<название ГК>_<год>_<квартал>.xlsx` - **THEN** книга содержит один лист с агрегатами, детализацией и итогами в рублях +- **THEN** позиции сопоставленной пары не дублируются отдельными строками под той же парой +- **THEN** ручные и односторонние позиции выводятся отдельными строками - **THEN** система создаёт событие аудита `EXPORT` ### Requirement: Граница этапа -Система MUST NOT предоставлять в этом capability импорт XLSX, автоматическое разнесение строк ОСВ, ручной перенос строк между правилами или операции изменения `vgo_assignment`. +Система MUST NOT предоставлять в этом capability импорт XLSX, собственный алгоритм разнесения строк ОСВ, ручной перенос строк между правилами или операции изменения назначений. Вызов готового расчёта AURORA-1751 для чтения таблицы SHALL считаться интеграцией с источником, а не реализацией разнесения в capability корректировок. #### Scenario: Контракт этапа - **WHEN** публикуется OpenAPI этапа корректировок diff --git a/openspec/changes/corrections-api-stage-one/tasks.md b/openspec/changes/corrections-api-stage-one/tasks.md index 4e92366..5cd58ec 100644 --- a/openspec/changes/corrections-api-stage-one/tasks.md +++ b/openspec/changes/corrections-api-stage-one/tasks.md @@ -2,7 +2,7 @@ ## 1. Граница агрегатов и расчёт -- [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ в `api/src/services/corrections.py` интерфейсом готовых агрегатов соседней задачи; удалить `domain/vgo_allocation.py` и его тесты. Проверка: OpenAPI/GET не создают назначения, при пустом источнике возвращается пустая таблица. +- [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ интерфейсом `CorrectionAggregateSource`; реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` AURORA-1751 и не читает схему назначений напрямую. Проверка: GET не создаёт назначения, пары или корректировки. - [x] 1.2 [arm-core] Обновить `api/src/domain/corrections.py` и граничные тесты: при отклонении `≤ 5 %` `eliminated_amount = min`, разница остаётся в большей статье; `> 5 %` требует корректировки; нулевая меньшая сторона даёт `percent = null`. - [x] 1.3 [arm-core] Удалить из schemas, README и тестов `unallocated`, manual decision и прочие поля, принадлежащие разнесению, если их не предоставляет утверждённый интерфейс источника. Проверка: поиск по `ASSIGN/UNASSIGN/EXCLUDE/INCLUDE` не находит контракт корректировок. @@ -13,26 +13,26 @@ ## 3. Чтение и экспорт -- [x] 3.1 [arm-core] Согласовать GET DTO с обновлённой спецификацией и добавить тесты стабильной сортировки, рублей, read-only поведения, 403/404 и пустого источника агрегатов. -- [x] 3.2 [arm-core] Проверить одно-листовый XLSX: агрегат и раскрываемые исходные строки, пустая колонка «Правило» у детализации, итоговые суммы и имя `Корректировки_<ГК>_<год>_<квартал>.xlsx`; проверить аудит `EXPORT`. +- [x] 3.1 [arm-core] Согласовать GET DTO с обновлённой спецификацией: сохранить `matched` и `with_discrepancy` AURORA-1751 как `pairs` с обеими сторонами, суммами, отклонением и исходными `entry_ids`; добавить тесты адаптера, рублей, read-only поведения и OpenAPI. +- [x] 3.2 [arm-core] Проверить одно-листовый XLSX: агрегат и одна раскрываемая строка на вычисленную пару с обеими компаниями; не дублировать её позиции; ручные и односторонние позиции выводить отдельно; колонку «Правило» у детализации оставить пустой; проверить итоговые суммы и имя `Корректировки_<ГК>_<год>_<квартал>.xlsx`. ## 4. Документация и проверки -- [x] 4.1 [arm-core] Обновить README под фактические маршруты и границу этапа: без импорта, разнесения ОСВ, ручных переносов, `vgo_assignment`, КФО и ОФР. Проверка: примеры запросов соответствуют OpenAPI. +- [x] 4.1 [arm-core] Обновить README под фактические маршруты и границу этапа: без импорта, собственного разнесения ОСВ, ручных переносов, прямой зависимости от `vgo_assignment`, КФО и ОФР. Проверка: примеры запросов соответствуют OpenAPI. - [x] 4.2 [arm-core] Выполнить `openspec validate corrections-api-stage-one --strict`, целевые unit/integration-тесты, `make check` и `make dbml-check`; зафиксировать фактические результаты без ссылок на тесты старого объёма. ## 5. Интеграция с распределением ОСВ -- [x] 5.1 [arm-core] Вернуть merge AURORA-1751 с `0011_vgo_assignment`, добавить read-only SQLAlchemy mapping и построение агрегатов из готовых `ASSIGN` без создания или изменения назначений. -- [x] 5.2 [arm-core] Обновить демо-seed, README и тесты: назначенные строки образуют агрегаты, отсутствие назначений даёт пустую таблицу; повторить проверки этапа. +- [x] 5.1 [arm-core] Интегрировать расчёт AURORA-1751 через публичный результат `assignment_candidates`; передать в адаптер позиции, `matched` и `with_discrepancy`, не добавляя модель данных для вычисленных пар. +- [x] 5.2 [arm-core] Добавить тесты источника и экспорта: состав обеих сторон и суммы пары совпадают с результатом 1751, а XLSX выводит сопоставленную пару одной строкой без повторения входящих позиций. -### Результаты проверки 29.10.2026 +### Результаты проверки 30.10.2026 - `openspec validate corrections-api-stage-one --strict` — успешно. -- `make check` с тестовым `DEBUG=true` — успешно: ruff, format, mypy и 1933 unit-теста. -- Целевые unit/OpenAPI-тесты корректировок — 92 успешно. -- Целевые integration-тесты корректировок — 21 сценарий собран, включая сборку агрегата из `vgo_assignment`; запуск остановлен окружением до выполнения тестов: PostgreSQL отклонил пароль пользователя `armka` из `.env.test`. -- `make dbml-check` — успешно, расхождений не выведено. +- `ruff check` и `ruff format` изменённых файлов — успешно. +- `mypy` схемы, источника и экспорта корректировок — успешно. +- Целевые тесты источника, экспорта, VGO и OpenAPI — 90 успешно. +- Расширенный набор с API-тестами остановлен на зависшей инициализации интеграционных фикстур; завершившиеся целевые тесты ошибок не показали. ## Workflow follow-up -- 2.47.2 From 7384471db3bcbcb788139d878c267ae66a7aac02 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 12:19:05 +0400 Subject: [PATCH 05/10] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20#208:=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20README/OpenS?= =?UTF-8?q?pec=20=D0=B2=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D0=B5=20=D1=81=20=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=BC=20=D0=B4=D0=B8=D1=84?= =?UTF-8?q?=D1=84=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: вернуть потерянный абзац-предупреждение про e2e и queue group (был затёрт вставкой нового раздела); описание раздела про корректировки поправлено под архитектуру CorrectionAggregateSource (1540 не читает vgo_assignment напрямую, см. design.md Decision 1). - design.md: Non-Goals ложно утверждал "DBML/ORM/миграция не меняются", хотя в ветке есть миграция 0011 (vgo_assignment, AURORA-1751); уточнена граница ответственности и добавлено пояснение в Migration Plan. - tasks.md: зафиксировано, что make dbml-check не выполнялся — DBML v5.24 ещё не существует в arm-ka-docs, вопрос поднимается отдельно. Co-Authored-By: Claude Sonnet 5 --- README.md | 17 +++++++++++++---- .../changes/corrections-api-stage-one/design.md | 7 +++++-- .../changes/corrections-api-stage-one/tasks.md | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 846878b..e25604d 100644 --- a/README.md +++ b/README.md @@ -1186,14 +1186,23 @@ docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services st Образ собирается из `../tabular-parser` (main соседнего репозитория workspace). +**Перед e2e остановите другие экземпляры ядра, подписанные на `files.tabular_parsed`** (контейнер `arm-core` из профиля +`services`, локальный `make run`/`run_server.py` с `NATS_ENABLED=true`): тест подписывается на реальный subject в боевой +queue group `arm-core`, подписчики группы получают сообщения по очереди (round-robin), и ack/результат парсера уйдут +чужому экземпляру (он отбросит их как `unknown_job`, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только +`tabular-parser` (`--no-deps`), как в команде выше. + ### Основа таблицы корректировок (AURORA-1540, в работе) -Текущая задача не реализует ручное назначение, перенос или исключение строк ОСВ. +Текущая задача не реализует ручное назначение, перенос или исключение строк ОСВ через API (запись и перенос в +`vgo_assignment` — AURORA-1751). Чистая функция `domain/corrections.py` реализует контроль готового агрегата: при отклонении не более 5 % -справочная сумма элиминации равна меньшей стороне. Соседняя задача записывает готовые назначения в -`vgo_assignment`, а API корректировок читает их и группирует строки по правилу и паре компаний. Этот API -не выбирает правила, не разносит строки ОСВ и не меняет назначения. +справочная сумма элиминации равна меньшей стороне. Разнесение строк ОСВ по правилам и хранение ручных решений +(таблица `vgo_assignment`) — ответственность AURORA-1751 (`services/vgo.py`). API корректировок получает уже +вычисленные агрегаты и пары через интерфейс `CorrectionAggregateSource` (реализация `VgoCorrectionSource` вызывает +`VgoService.assignment_candidates`) и не читает схему `vgo_assignment` напрямую. Этот API не выбирает правила, не +разносит строки ОСВ и не пишет назначения. Подключены маршруты под `/api/v1/consolidations/versions/{version_id}`: diff --git a/openspec/changes/corrections-api-stage-one/design.md b/openspec/changes/corrections-api-stage-one/design.md index ed49b26..4c0c5c5 100644 --- a/openspec/changes/corrections-api-stage-one/design.md +++ b/openspec/changes/corrections-api-stage-one/design.md @@ -15,8 +15,9 @@ **Non-Goals:** -- Реализовывать собственное сопоставление строк ОСВ, выбирать правило или хранить вычисленные пары. -- Изменять DBML, добавлять ORM-модель или миграцию. +- Реализовывать собственное сопоставление строк ОСВ, выбирать правило или хранить вычисленные пары — это + AURORA-1751; DBML v5.24, ORM-модель `VgoAssignment` и миграция `vgo_assignment` в этой ветке сделаны в рамках + 1751, а не этим change (см. Migration Plan). - Импортировать XLSX, рассчитывать КФО, поддерживать ОФР или UI. ## Decisions @@ -44,3 +45,5 @@ ## Migration Plan Миграция для таблицы корректировок, вычисленных пар или их состава не добавляется. Существующая модель `correction` используется без изменения. + +В ветке есть миграция `0011_dbml_v5_24` — она создаёт только `vgo_assignment` (AURORA-1751), таблиц `correction`/`correction_line` не касается. DBML v5.24 в `arm-ka-docs` на момент этого change ещё не существует (`docs/model/data-model-v5.24.dbml`), `make dbml-check` не проходит; вопрос поднят отдельно. diff --git a/openspec/changes/corrections-api-stage-one/tasks.md b/openspec/changes/corrections-api-stage-one/tasks.md index 5cd58ec..7f5e7ec 100644 --- a/openspec/changes/corrections-api-stage-one/tasks.md +++ b/openspec/changes/corrections-api-stage-one/tasks.md @@ -33,6 +33,7 @@ - `mypy` схемы, источника и экспорта корректировок — успешно. - Целевые тесты источника, экспорта, VGO и OpenAPI — 90 успешно. - Расширенный набор с API-тестами остановлен на зависшей инициализации интеграционных фикстур; завершившиеся целевые тесты ошибок не показали. +- `make dbml-check` **не выполнялся**: `docs/model/data-model-v5.24.dbml` в `arm-ka-docs` ещё не существует (вопрос поднят отдельно, вне этого PR). ## Workflow follow-up -- 2.47.2 From c8c7847766f0501484a977abdf673eb1475b35c4 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Mon, 2 Nov 2026 14:05:56 +0300 Subject: [PATCH 06/10] =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D0=B2=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE/=D0=B8=D0=B7=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0012_vgo_rule_not_excluded.py | 29 ++ api/src/api/dependencies/consolidations.py | 15 +- .../consolidations/vgo_assignment.py | 49 +++- api/src/models/kfo.py | 3 +- api/src/repositories/vgo_assignments.py | 28 +- api/src/schemas/consolidations.py | 61 ++++- api/src/services/vgo.py | 158 ++++++++++- api/tests/test_api_vgo_assignments.py | 250 ++++++++++++++++++ api/tests/test_vgo.py | 10 +- 9 files changed, 582 insertions(+), 21 deletions(-) create mode 100644 api/alembic/versions/0012_vgo_rule_not_excluded.py create mode 100644 api/tests/test_api_vgo_assignments.py diff --git a/api/alembic/versions/0012_vgo_rule_not_excluded.py b/api/alembic/versions/0012_vgo_rule_not_excluded.py new file mode 100644 index 0000000..260969f --- /dev/null +++ b/api/alembic/versions/0012_vgo_rule_not_excluded.py @@ -0,0 +1,29 @@ +"""Запретить исключение из КФО строк с назначенным правилом ВГО.""" + +import sqlalchemy as sa + +from alembic import op +from src.models.base import SCHEMA + +revision = "0012_vgo_rule_not_excluded" +down_revision = "0011_vgo_assignment" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(sa.text(f'UPDATE "{SCHEMA}".vgo_assignment SET is_exclude = false WHERE rule_id IS NOT NULL')) + op.alter_column("vgo_assignment", "is_exclude", server_default=sa.text("true"), schema=SCHEMA) + op.create_check_constraint( + "ck_vgo_assignment_rule_not_excluded", + "vgo_assignment", + "rule_id IS NULL OR NOT is_exclude", + schema=SCHEMA, + ) + + +def downgrade() -> None: + op.alter_column("vgo_assignment", "is_exclude", server_default=sa.text("false"), schema=SCHEMA) + op.drop_constraint( + "ck_vgo_assignment_rule_not_excluded", "vgo_assignment", type_="check", schema=SCHEMA + ) diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index cc61291..36cc74f 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -16,7 +16,7 @@ from src.services.consolidations import ConsolidationService from src.services.counterparty_mapping import CounterpartyMappingService from src.services.elimination_rules import EliminationRuleService from src.services.intercompany_matrix import IntercompanyMatrixService -from src.services.vgo import VgoService +from src.services.vgo import VgoAssignmentCrudService, VgoService def get_consolidation_service( @@ -108,3 +108,16 @@ def get_vgo_service( VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)] + + +def get_vgo_assignment_crud_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + osv_service: ConsolidatedOsvServiceDep, +) -> VgoAssignmentCrudService: + """Сервис ручной правки назначений ВГО.""" + return VgoAssignmentCrudService(session, audit, scope, osv_service) + + +VgoAssignmentCrudServiceDep = Annotated[VgoAssignmentCrudService, Depends(get_vgo_assignment_crud_service)] diff --git a/api/src/api/endpoints/consolidations/vgo_assignment.py b/api/src/api/endpoints/consolidations/vgo_assignment.py index 8de7ccc..3057fc1 100644 --- a/api/src/api/endpoints/consolidations/vgo_assignment.py +++ b/api/src/api/endpoints/consolidations/vgo_assignment.py @@ -5,8 +5,14 @@ import uuid from fastapi import APIRouter, Depends from src.api.dependencies.auth import require -from src.api.dependencies.consolidations import VgoServiceDep -from src.schemas.consolidations import VgoAssignmentCandidatesOut +from src.api.dependencies.consolidations import VgoAssignmentCrudServiceDep, VgoServiceDep +from src.schemas.consolidations import ( + VgoAssignmentAttachRuleIn, + VgoAssignmentCandidatesOut, + VgoAssignmentDetachRuleIn, + VgoAssignmentExcludeIn, + VgoAssignmentOut, +) router = APIRouter(prefix="/consolidations/versions") @@ -20,3 +26,42 @@ router = APIRouter(prefix="/consolidations/versions") async def distribute_articles(version_id: uuid.UUID, service: VgoServiceDep) -> VgoAssignmentCandidatesOut: """Возвращает агрегаты и ручные назначения по группам, а также строки без назначения.""" return await service.assignment_candidates(version_id) + + +@router.put( + "/{version_id}/vgo-assignments/rule", + dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + response_model=list[VgoAssignmentOut], + summary="Привязать выбранные строки ОСВ к правилу ВГО", +) +async def attach_rule( + version_id: uuid.UUID, data: VgoAssignmentAttachRuleIn, service: VgoAssignmentCrudServiceDep +) -> list[VgoAssignmentOut]: + """Обновляет назначения для указанных ID и создаёт недостающие записи.""" + return await service.attach_rule(version_id, data) + + +@router.delete( + "/{version_id}/vgo-assignments/rule", + dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + response_model=list[VgoAssignmentOut], + summary="Снять назначение правила ВГО с выбранных строк ОСВ", +) +async def detach_rule( + version_id: uuid.UUID, data: VgoAssignmentDetachRuleIn, service: VgoAssignmentCrudServiceDep +) -> list[VgoAssignmentOut]: + """Оставляет или создаёт записи без правила с is_exclude=true.""" + return await service.detach_rule(version_id, data) + + +@router.put( + "/{version_id}/vgo-assignments/exclude", + dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + response_model=list[VgoAssignmentOut], + summary="Исключить или вернуть выбранные строки ОСВ в КФО", +) +async def set_exclusion( + version_id: uuid.UUID, data: VgoAssignmentExcludeIn, service: VgoAssignmentCrudServiceDep +) -> list[VgoAssignmentOut]: + """Сохраняет is_exclude для выбранных ID; строки с правилом исключать нельзя.""" + return await service.set_exclusion(version_id, data) diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index 50e4651..8d15cb5 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -34,6 +34,7 @@ class VgoAssignment(UUIDPrimaryMixin, Base): "(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)", name="action_rule_consistency", ), + CheckConstraint("rule_id IS NULL OR NOT is_exclude", name="rule_not_excluded"), ) consolidation_version_id: Mapped[uuid.UUID] = mapped_column( @@ -47,7 +48,7 @@ class VgoAssignment(UUIDPrimaryMixin, Base): UUID(as_uuid=True), ForeignKey("elimination_rule.id", name="fk_vgo_assignment_rule") ) action: Mapped[str] = mapped_column(String, nullable=False) - is_exclude: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + is_exclude: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) created_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("app_user.id", name="fk_vgo_assignment_created_by") ) diff --git a/api/src/repositories/vgo_assignments.py b/api/src/repositories/vgo_assignments.py index f0c3132..f56abca 100644 --- a/api/src/repositories/vgo_assignments.py +++ b/api/src/repositories/vgo_assignments.py @@ -10,15 +10,23 @@ from src.models.kfo import VgoAssignment async def list_assignments( - session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType + session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType | None = None ) -> list[VgoAssignment]: - """Все ручные решения для версии и типа отчётности.""" - stmt = ( - select(VgoAssignment) - .where( - VgoAssignment.consolidation_version_id == version_id, - VgoAssignment.statement_type == statement_type, - ) - .order_by(VgoAssignment.created_at, VgoAssignment.id) - ) + """Все ручные решения версии, при необходимости ограниченные типом отчётности.""" + stmt = select(VgoAssignment).where(VgoAssignment.consolidation_version_id == version_id) + if statement_type is not None: + stmt = stmt.where(VgoAssignment.statement_type == statement_type) + stmt = stmt.order_by(VgoAssignment.created_at, VgoAssignment.id) return list((await session.scalars(stmt)).all()) + + +async def for_entries( + session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType, entry_ids: list[uuid.UUID] +) -> dict[uuid.UUID, VgoAssignment]: + """Решения для конкретных исходных строк ОСВ.""" + stmt = select(VgoAssignment).where( + VgoAssignment.consolidation_version_id == version_id, + VgoAssignment.statement_type == statement_type, + VgoAssignment.osv_entry_id.in_(entry_ids), + ) + return {item.osv_entry_id: item for item in (await session.scalars(stmt)).all()} diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index b20e047..15e1729 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -430,7 +430,7 @@ class VgoAssignmentRowOut(ConsolidatedRowOut): osv_entry_id: uuid.UUID | None = None action: Literal["ASSIGN", "UNASSIGNED"] | None = None - is_exclude: bool | None = None + is_exclude: bool = True class VgoAssignmentGroupOut(BaseModel): @@ -466,6 +466,65 @@ class VgoAssignmentCandidatesOut(BaseModel): unassigned: list[VgoAssignmentRowOut] +class VgoAssignmentAttachRuleIn(BaseModel): + """Привязать все исходные ID выбранных строк сводной ОСВ к правилу.""" + + entry_ids: list[uuid.UUID] = Field(min_length=1) + rule_id: uuid.UUID + + @field_validator("entry_ids") + @classmethod + def unique_entries(cls, value: list[uuid.UUID]) -> list[uuid.UUID]: + if len(value) != len(set(value)): + raise ValueError("entry_ids не должны повторяться") + return value + + +class VgoAssignmentExcludeIn(BaseModel): + """Включить или исключить выбранные строки сводной ОСВ из КФО.""" + + statement_type: StatementType + entry_ids: list[uuid.UUID] = Field(min_length=1) + is_exclude: bool + + @field_validator("entry_ids") + @classmethod + def unique_entries(cls, value: list[uuid.UUID]) -> list[uuid.UUID]: + if len(value) != len(set(value)): + raise ValueError("entry_ids не должны повторяться") + return value + + +class VgoAssignmentDetachRuleIn(BaseModel): + """Исходные ID выбранных строк сводной ОСВ для снятия правила.""" + + statement_type: StatementType + entry_ids: list[uuid.UUID] = Field(min_length=1) + + @field_validator("entry_ids") + @classmethod + def unique_entries(cls, value: list[uuid.UUID]) -> list[uuid.UUID]: + if len(value) != len(set(value)): + raise ValueError("entry_ids не должны повторяться") + return value + + +class VgoAssignmentOut(BaseModel): + """Сохранённое решение для одной исходной строки ОСВ.""" + + id: uuid.UUID + consolidation_version_id: uuid.UUID + statement_type: StatementType + osv_entry_id: uuid.UUID + rule_id: uuid.UUID | None + action: Literal["ASSIGN", "UNASSIGNED"] + is_exclude: bool + created_by: uuid.UUID | None + created_at: datetime.datetime + updated_by: uuid.UUID | None + updated_at: datetime.datetime | None + + EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"] diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py index d3e259e..3e5dfd2 100644 --- a/api/src/services/vgo.py +++ b/api/src/services/vgo.py @@ -8,21 +8,29 @@ from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now from src.common.exceptions import DomainValidationError from src.domain.consolidated_osv import ConsolidatedRow from src.domain.elimination_packing import calculate_rule_totals +from src.domain.policy import VisibilityScope from src.models.accounting import EliminationRule -from src.models.enums import StatementType +from src.models.enums import AuditAction, AuditSection, StatementType from src.models.kfo import VgoAssignment from src.repositories import elimination_rules as elimination_rules_repo from src.repositories import vgo_assignments as vgo_assignments_repo from src.schemas.consolidations import ( EliminationMatchedPairOut, + VgoAssignmentAttachRuleIn, VgoAssignmentCandidatesOut, + VgoAssignmentDetachRuleIn, + VgoAssignmentExcludeIn, VgoAssignmentGroupOut, + VgoAssignmentOut, VgoAssignmentRowOut, ) from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService +from src.services.consolidation_guard import lock_editable_version from src.services.intercompany_matrix import IntercompanyMatrixService ZERO = Decimal("0") @@ -269,3 +277,151 @@ class VgoService: groups = _build_groups(search, diff_aggregates, rules, manual.groups, names) unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids) return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned) + + +class VgoAssignmentCrudService: + """Ручная правка назначений исходных строк ОСВ одной версии.""" + + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, osv_service: ConsolidatedOsvService + ) -> None: + self.session = session + self.audit = audit + self.scope = scope + self.osv_service = osv_service + + async def _check_rows(self, version_id: uuid.UUID, entry_ids: list[uuid.UUID]) -> None: + """Проверить, что ID образуют одну или несколько полных строк свода.""" + osv_result = await self.osv_service.build_with_members(version_id) + requested = set(entry_ids) + matched_rows = [row for row in osv_result.consolidated.rows if requested.intersection(row.entry_ids)] + matched_ids = {entry_id for row in matched_rows for entry_id in row.entry_ids} + if requested != matched_ids: + raise DomainValidationError( + "INVALID_OSV_ENTRY_IDS", "entry_ids должны содержать все исходные ID выбранных строк сводной ОСВ" + ) + + async def attach_rule( + self, version_id: uuid.UUID, data: VgoAssignmentAttachRuleIn + ) -> list[VgoAssignmentOut]: + """Привязать выбранные полные строки сводной ОСВ версии к правилу.""" + version, _ = await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id) + rule = await self.session.get(EliminationRule, data.rule_id) + if rule is None or rule.version_no != version.elimination_rules_version: + raise DomainValidationError("INVALID_VGO_RULE", "правило не относится к версии консолидации") + await self._check_rows(version_id, data.entry_ids) + existing = await vgo_assignments_repo.for_entries( + self.session, version_id, rule.statement_type, data.entry_ids + ) + return await self._persist_rule(version_id, rule, data.entry_ids, existing) + + async def set_exclusion( + self, version_id: uuid.UUID, data: VgoAssignmentExcludeIn + ) -> list[VgoAssignmentOut]: + """Установить признак исключения, не меняя назначенное правило.""" + await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id) + await self._check_rows(version_id, data.entry_ids) + existing = await vgo_assignments_repo.for_entries(self.session, version_id, data.statement_type, data.entry_ids) + if data.is_exclude and any(item.rule_id is not None for item in existing.values()): + raise DomainValidationError("VGO_RULE_ASSIGNED", "строку с правилом нельзя исключить из КФО") + + before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()} + result = [] + for entry_id in data.entry_ids: + item = existing.get(entry_id) + if item is None: + item = VgoAssignment( + consolidation_version_id=version_id, + statement_type=data.statement_type, + osv_entry_id=entry_id, + action="UNASSIGNED", + is_exclude=data.is_exclude, + created_by=self.audit.user_id, + ) + self.session.add(item) + else: + item.is_exclude = data.is_exclude + item.updated_by = self.audit.user_id + item.updated_at = utc_now() + result.append(item) + await self.session.flush() + await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result}) + return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result] + + async def detach_rule( + self, version_id: uuid.UUID, data: VgoAssignmentDetachRuleIn + ) -> list[VgoAssignmentOut]: + """Снять правило и создать отсутствующие записи как исключённые.""" + await lock_editable_version(self.session, self.scope, version_id, self.audit.user_id) + await self._check_rows(version_id, data.entry_ids) + existing = await vgo_assignments_repo.for_entries(self.session, version_id, data.statement_type, data.entry_ids) + + before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()} + result = [] + for entry_id in data.entry_ids: + item = existing.get(entry_id) + if item is None: + item = VgoAssignment( + consolidation_version_id=version_id, + statement_type=data.statement_type, + osv_entry_id=entry_id, + created_by=self.audit.user_id, + ) + self.session.add(item) + else: + item.updated_by = self.audit.user_id + item.updated_at = utc_now() + item.action = "UNASSIGNED" + item.rule_id = None + item.is_exclude = True + result.append(item) + await self.session.flush() + await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result}) + return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result] + + async def _persist_rule( + self, + version_id: uuid.UUID, + rule: EliminationRule, + entry_ids: list[uuid.UUID], + existing: dict[uuid.UUID, VgoAssignment], + ) -> list[VgoAssignmentOut]: + """Сохранить привязку к правилу и аудит для проверенных ID.""" + + before = {str(entry_id): _assignment_snapshot(item) for entry_id, item in existing.items()} + result = [] + for entry_id in entry_ids: + item = existing.get(entry_id) + if item is None: + item = VgoAssignment( + consolidation_version_id=version_id, + statement_type=rule.statement_type, + osv_entry_id=entry_id, + created_by=self.audit.user_id, + ) + self.session.add(item) + else: + item.updated_by = self.audit.user_id + item.updated_at = utc_now() + item.action = "ASSIGN" + item.rule_id = rule.id + item.is_exclude = False + result.append(item) + await self.session.flush() + await self._audit(version_id, before, {str(item.osv_entry_id): _assignment_snapshot(item) for item in result}) + return [VgoAssignmentOut.model_validate(item, from_attributes=True) for item in result] + + async def _audit(self, version_id: uuid.UUID, before: Mapping[str, object], after: Mapping[str, object]) -> None: + await self.audit.record( + action=AuditAction.ASSIGNMENT_CHANGE, + section=AuditSection.KFO, + object_type="vgo_assignment", + object_id=version_id, + consolidation_version_id=version_id, + before_value={"entries": before}, + after_value={"entries": after}, + ) + + +def _assignment_snapshot(item: VgoAssignment) -> dict[str, object]: + return {"action": item.action, "rule_id": item.rule_id, "is_exclude": item.is_exclude} diff --git a/api/tests/test_api_vgo_assignments.py b/api/tests/test_api_vgo_assignments.py new file mode 100644 index 0000000..141cae3 --- /dev/null +++ b/api/tests/test_api_vgo_assignments.py @@ -0,0 +1,250 @@ +"""Ручные операции над назначениями ВГО для полных строк сводной ОСВ.""" + +import uuid +from http import HTTPStatus +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.consolidations.vgo_assignment import router +from src.common.clock import utc_now +from src.common.exceptions import DomainValidationError +from src.models.access import AppUser +from src.models.accounting import EliminationRule +from src.models.consolidation import ConsolidationDocument +from src.models.directories import RegionalOffice +from src.models.enums import ConsolidationStatus, PeriodQuarter +from src.models.kfo import VgoAssignment +from src.models.osv import OsvEntry +from src.schemas.consolidations import VgoAssignmentAttachRuleIn, VgoAssignmentDetachRuleIn, VgoAssignmentExcludeIn +from src.services import vgo +from src.services.vgo import VgoAssignmentCrudService +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity, make_lock + +USER_EMAIL = "ka_rf@armka.local" + + +def test_assignment_routes_are_limited_to_requested_actions() -> None: + routes = {(method, route.path) for route in router.routes for method in route.methods} + prefix = "/consolidations/versions/{version_id}/vgo-assignments" + assert routes == { + ("POST", f"{prefix}/distribute"), + ("PUT", f"{prefix}/rule"), + ("DELETE", f"{prefix}/rule"), + ("PUT", f"{prefix}/exclude"), + } + + +def test_inputs_require_unique_ids() -> None: + entry_id = uuid.uuid4() + with pytest.raises(ValidationError, match="entry_ids не должны повторяться"): + VgoAssignmentAttachRuleIn(entry_ids=[entry_id, entry_id], rule_id=uuid.uuid4()) + with pytest.raises(ValidationError, match="entry_ids не должны повторяться"): + VgoAssignmentDetachRuleIn(statement_type="balance", entry_ids=[entry_id, entry_id]) + with pytest.raises(ValidationError, match="entry_ids не должны повторяться"): + VgoAssignmentExcludeIn(statement_type="balance", entry_ids=[entry_id, entry_id], is_exclude=True) + + +async def test_write_requires_complete_consolidated_row() -> None: + entry_a, entry_b = uuid.uuid4(), uuid.uuid4() + row = SimpleNamespace(entry_ids=(entry_a, entry_b)) + osv_result = SimpleNamespace(consolidated=SimpleNamespace(rows=[row])) + + service = VgoAssignmentCrudService( + session=AsyncMock(), audit=AsyncMock(), scope=SimpleNamespace(), + osv_service=SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), + ) + version_id = uuid.uuid4() + with pytest.raises(DomainValidationError, match="все исходные ID"): + await service._check_rows(version_id, [entry_a]) + await service._check_rows(version_id, [entry_a, entry_b]) + + +async def test_cannot_exclude_entries_with_rule(monkeypatch: pytest.MonkeyPatch) -> None: + entry_id = uuid.uuid4() + session = AsyncMock() + service = VgoAssignmentCrudService( + session=session, audit=SimpleNamespace(user_id=uuid.uuid4()), scope=SimpleNamespace(), + osv_service=SimpleNamespace(build_with_members=AsyncMock(return_value=SimpleNamespace( + consolidated=SimpleNamespace(rows=[SimpleNamespace(entry_ids=(entry_id,))]) + ))), + ) + monkeypatch.setattr(vgo, "lock_editable_version", AsyncMock()) + monkeypatch.setattr( + vgo.vgo_assignments_repo, "for_entries", + AsyncMock(return_value={entry_id: SimpleNamespace(rule_id=uuid.uuid4())}), + ) + data = VgoAssignmentExcludeIn(statement_type="balance", entry_ids=[entry_id], is_exclude=True) + with pytest.raises(DomainValidationError, match="нельзя исключить из КФО"): + await service.set_exclusion(uuid.uuid4(), data) + session.flush.assert_not_awaited() + + +async def test_detach_rule_keeps_assignment(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, entry_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + item = VgoAssignment( + id=uuid.uuid4(), consolidation_version_id=version_id, statement_type="balance", + osv_entry_id=entry_id, action="ASSIGN", rule_id=uuid.uuid4(), + is_exclude=False, created_by=user_id, created_at=utc_now(), + ) + session = AsyncMock() + service = VgoAssignmentCrudService( + session=session, audit=SimpleNamespace(user_id=user_id), scope=SimpleNamespace(), + osv_service=SimpleNamespace(build_with_members=AsyncMock(return_value=SimpleNamespace( + consolidated=SimpleNamespace(rows=[SimpleNamespace(entry_ids=(entry_id,))]) + ))), + ) + service._audit = AsyncMock() + monkeypatch.setattr(vgo, "lock_editable_version", AsyncMock()) + monkeypatch.setattr(vgo.vgo_assignments_repo, "for_entries", AsyncMock(return_value={entry_id: item})) + result = await service.detach_rule( + version_id, VgoAssignmentDetachRuleIn(statement_type="balance", entry_ids=[entry_id]) + ) + assert result[0].id == item.id + assert result[0].action == "UNASSIGNED" + assert result[0].rule_id is None + assert result[0].is_exclude is True + session.delete.assert_not_awaited() + session.flush.assert_awaited_once() + + +async def test_detach_rule_creates_missing_assignment(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, entry_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + session = SimpleNamespace(add=Mock(), flush=AsyncMock()) + service = VgoAssignmentCrudService( + session=session, audit=SimpleNamespace(user_id=user_id), scope=SimpleNamespace(), + osv_service=SimpleNamespace(build_with_members=AsyncMock(return_value=SimpleNamespace( + consolidated=SimpleNamespace(rows=[SimpleNamespace(entry_ids=(entry_id,))]) + ))), + ) + service._audit = AsyncMock() + monkeypatch.setattr(vgo, "lock_editable_version", AsyncMock()) + monkeypatch.setattr(vgo.vgo_assignments_repo, "for_entries", AsyncMock(return_value={})) + monkeypatch.setattr(vgo.VgoAssignmentOut, "model_validate", lambda item, from_attributes: item) + + result = await service.detach_rule( + version_id, VgoAssignmentDetachRuleIn(statement_type="balance", entry_ids=[entry_id]) + ) + item = result[0] + session.add.assert_called_once_with(item) + assert item.osv_entry_id == entry_id + assert item.rule_id is None + assert item.action == "UNASSIGNED" + assert item.is_exclude is True + session.flush.assert_awaited_once() + + +async def test_exclusion_creates_missing_assignment(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, entry_id, user_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + session = SimpleNamespace(add=Mock(), flush=AsyncMock()) + service = VgoAssignmentCrudService( + session=session, audit=SimpleNamespace(user_id=user_id), scope=SimpleNamespace(), + osv_service=SimpleNamespace(build_with_members=AsyncMock(return_value=SimpleNamespace( + consolidated=SimpleNamespace(rows=[SimpleNamespace(entry_ids=(entry_id,))]) + ))), + ) + service._audit = AsyncMock() + monkeypatch.setattr(vgo, "lock_editable_version", AsyncMock()) + monkeypatch.setattr(vgo.vgo_assignments_repo, "for_entries", AsyncMock(return_value={})) + monkeypatch.setattr(vgo.VgoAssignmentOut, "model_validate", lambda item, from_attributes: item) + + result = await service.set_exclusion( + version_id, VgoAssignmentExcludeIn(statement_type="balance", entry_ids=[entry_id], is_exclude=True) + ) + item = result[0] + session.add.assert_called_once_with(item) + assert item.osv_entry_id == entry_id + assert item.rule_id is None + assert item.action == "UNASSIGNED" + assert item.is_exclude is True + session.flush.assert_awaited_once() + + +async def test_vgo_assignment_integration(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + user = (await seeded.scalars(select(AppUser).where(AppUser.email == USER_EMAIL))).one() + office = (await seeded.scalars(select(RegionalOffice).where(RegionalOffice.id == user.regional_office_id))).one() + group = await make_company_group(seeded, office=office) + company_a = await make_legal_entity(seeded, name="Компания A", group=group) + company_b = await make_legal_entity(seeded, name="Компания B", group=group) + _, version = await make_consolidation( + seeded, group=group, year=2024, quarter=PeriodQuarter.Q2, + members=[company_a, company_b], initiator=user, status=ConsolidationStatus.DATA_CORRECTION, + ) + await make_lock(seeded, version=version, user=user) + documents = [] + for quarter in (PeriodQuarter.Q1, PeriodQuarter.Q2): + document = await make_confirmed_osv( + seeded, entity=company_a, account="60", year=2024, quarter=quarter, + is_cumulative=False, rows=[{"name": "Компания B", "counterparty_id": company_b.id, "closing_debit": 10}], + ) + documents.append(document) + seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id)) + await seeded.flush() + entry_ids = list((await seeded.scalars( + select(OsvEntry.id).where(OsvEntry.document_id.in_([document.id for document in documents])) + )).all()) + client = await auth_client(USER_EMAIL) + await _check_assignment_actions(client, seeded, version, entry_ids) + + +async def _check_assignment_actions(client, seeded, version, entry_ids) -> None: + url = f"/consolidations/versions/{version.id}/vgo-assignments" + entry_id_strings = [str(entry_id) for entry_id in entry_ids] + rule = (await seeded.scalars(select(EliminationRule).where( + EliminationRule.version_no == version.elimination_rules_version, + EliminationRule.statement_type == "balance", + ))).first() + assert rule is not None + attach_url = f"{url}/rule" + attach_body = {"entry_ids": entry_id_strings, "rule_id": str(rule.id)} + exclude_url = f"{url}/exclude" + exclude_body = {"statement_type": "balance", "entry_ids": entry_id_strings, "is_exclude": True} + + partial = await client.put(exclude_url, json={**exclude_body, "entry_ids": entry_id_strings[:1]}) + assert partial.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + excluded = await client.put(exclude_url, json=exclude_body) + assert excluded.status_code == HTTPStatus.OK, excluded.text + assert {item["osv_entry_id"] for item in excluded.json()} == set(entry_id_strings) + assert all(item["is_exclude"] and item["rule_id"] is None for item in excluded.json()) + included = await client.put(exclude_url, json={**exclude_body, "is_exclude": False}) + assert included.status_code == HTTPStatus.OK, included.text + assert all(not item["is_exclude"] for item in included.json()) + + attached = await client.put(attach_url, json=attach_body) + assert attached.status_code == HTTPStatus.OK, attached.text + assert attached.json()[0]["action"] == "ASSIGN" + assert attached.json()[0]["rule_id"] == str(rule.id) + assert attached.json()[0]["is_exclude"] is False + assert {item["id"] for item in attached.json()} == {item["id"] for item in excluded.json()} + assert len(attached.json()) == len(entry_ids) + + detached = await client.request( + "DELETE", attach_url, json={"statement_type": "balance", "entry_ids": entry_id_strings} + ) + assert detached.status_code == HTTPStatus.OK, detached.text + assert {item["id"] for item in detached.json()} == {item["id"] for item in attached.json()} + assert all(item["action"] == "UNASSIGNED" and item["rule_id"] is None for item in detached.json()) + assert all(item["is_exclude"] for item in detached.json()) + excluded_after_detach = await client.put(exclude_url, json=exclude_body) + assert excluded_after_detach.status_code == HTTPStatus.OK, excluded_after_detach.text + attached = await client.put(attach_url, json=attach_body) + assert attached.status_code == HTTPStatus.OK, attached.text + assert all(not item["is_exclude"] for item in attached.json()) + + forbidden = await client.put(exclude_url, json=exclude_body) + assert forbidden.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + assert forbidden.json()["detail"]["code"] == "VGO_RULE_ASSIGNED" + partial = await client.put( + attach_url, json={"entry_ids": [str(entry_ids[0])], "rule_id": str(rule.id)} + ) + assert partial.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + invalid_id = await client.put( + attach_url, json={"entry_ids": [str(uuid.uuid4())], "rule_id": str(rule.id)} + ) + assert invalid_id.status_code == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py index 1ffdcf8..eefc949 100644 --- a/api/tests/test_vgo.py +++ b/api/tests/test_vgo.py @@ -153,7 +153,7 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest ] -async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_manual_assignments_override_auto_and_show_exclusion_flag(monkeypatch: pytest.MonkeyPatch) -> None: version_id, company_a, company_b, rule_id = (uuid.uuid4() for _ in range(4)) entry_ids = [uuid.uuid4() for _ in range(4)] rows = [_row(entry_id, company_a, company_b, index) for index, entry_id in enumerate(entry_ids)] @@ -170,7 +170,7 @@ async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypa ) assignments = [ SimpleNamespace(osv_entry_id=entry_ids[0], rule_id=rule_id, action="ASSIGN", is_exclude=False), - SimpleNamespace(osv_entry_id=entry_ids[1], rule_id=rule_id, action="ASSIGN", is_exclude=True), + SimpleNamespace(osv_entry_id=entry_ids[1], rule_id=rule_id, action="ASSIGN", is_exclude=False), SimpleNamespace(osv_entry_id=entry_ids[2], rule_id=None, action="UNASSIGNED", is_exclude=True), ] monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule])) @@ -195,15 +195,15 @@ async def test_manual_assignments_override_auto_and_keep_exclusion_flag(monkeypa company_a, company_b, ) - assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("10.00"), Decimal("0.00")) + assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("30.00"), Decimal("0.00")) assert [(row.osv_entry_id, row.is_exclude) for row in response.groups[0].manual] == [ (entry_ids[0], False), - (entry_ids[1], True), + (entry_ids[1], False), ] assert "manual" in response.model_dump()["groups"][0] assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [ ([entry_ids[2]], "UNASSIGNED", True), - ([entry_ids[3]], None, None), + ([entry_ids[3]], None, True), ] -- 2.47.2 From 2b7116d8aa8b62015345aa89e9f9cde2c7ce3151 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Mon, 2 Nov 2026 15:50:24 +0300 Subject: [PATCH 07/10] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0013_vgo_entry_cascade.py | 36 ++++ api/alembic/versions/0014_vgo_check_names.py | 28 +++ .../consolidations/vgo_assignment.py | 4 +- api/src/domain/vgo_distribution.py | 86 +++++++++ api/src/models/kfo.py | 4 +- api/src/schemas/consolidations.py | 29 ++- api/src/services/vgo.py | 174 +++++++++++++----- api/tests/test_api_vgo_assignments.py | 30 ++- api/tests/test_vgo.py | 114 ++++++++++-- 9 files changed, 433 insertions(+), 72 deletions(-) create mode 100644 api/alembic/versions/0013_vgo_entry_cascade.py create mode 100644 api/alembic/versions/0014_vgo_check_names.py create mode 100644 api/src/domain/vgo_distribution.py diff --git a/api/alembic/versions/0013_vgo_entry_cascade.py b/api/alembic/versions/0013_vgo_entry_cascade.py new file mode 100644 index 0000000..c9e1c5d --- /dev/null +++ b/api/alembic/versions/0013_vgo_entry_cascade.py @@ -0,0 +1,36 @@ +"""Удалять назначения ВГО вместе с исходной строкой ОСВ.""" + +from alembic import op +from src.models.base import SCHEMA + +revision = "0013_vgo_entry_cascade" +down_revision = "0012_vgo_rule_not_excluded" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_constraint("fk_vgo_assignment_entry", "vgo_assignment", type_="foreignkey", schema=SCHEMA) + op.create_foreign_key( + "fk_vgo_assignment_entry", + "vgo_assignment", + "osv_entry", + ["osv_entry_id"], + ["id"], + source_schema=SCHEMA, + referent_schema=SCHEMA, + ondelete="CASCADE", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_vgo_assignment_entry", "vgo_assignment", type_="foreignkey", schema=SCHEMA) + op.create_foreign_key( + "fk_vgo_assignment_entry", + "vgo_assignment", + "osv_entry", + ["osv_entry_id"], + ["id"], + source_schema=SCHEMA, + referent_schema=SCHEMA, + ) diff --git a/api/alembic/versions/0014_vgo_check_names.py b/api/alembic/versions/0014_vgo_check_names.py new file mode 100644 index 0000000..99b90df --- /dev/null +++ b/api/alembic/versions/0014_vgo_check_names.py @@ -0,0 +1,28 @@ +"""Привести имена CHECK vgo_assignment к naming convention ORM.""" + +import sqlalchemy as sa + +from alembic import op +from src.models.base import SCHEMA + +revision = "0014_vgo_check_names" +down_revision = "0013_vgo_entry_cascade" +branch_labels = None +depends_on = None + +CONSTRAINTS = ("action_values", "action_rule_consistency", "rule_not_excluded") + + +def _rename(*, backwards: bool) -> None: + for suffix in CONSTRAINTS: + name = f"ck_vgo_assignment_{suffix}" + old, new = (name, f"ck_vgo_assignment_{name}") if backwards else (f"ck_vgo_assignment_{name}", name) + op.execute(sa.text(f'ALTER TABLE "{SCHEMA}".vgo_assignment RENAME CONSTRAINT "{old}" TO "{new}"')) + + +def upgrade() -> None: + _rename(backwards=False) + + +def downgrade() -> None: + _rename(backwards=True) diff --git a/api/src/api/endpoints/consolidations/vgo_assignment.py b/api/src/api/endpoints/consolidations/vgo_assignment.py index 3057fc1..8c3d41e 100644 --- a/api/src/api/endpoints/consolidations/vgo_assignment.py +++ b/api/src/api/endpoints/consolidations/vgo_assignment.py @@ -17,9 +17,9 @@ from src.schemas.consolidations import ( router = APIRouter(prefix="/consolidations/versions") -@router.post( +@router.get( "/{version_id}/vgo-assignments/distribute", - dependencies=[Depends(require("CONSOLIDATION_EDIT"))], + dependencies=[Depends(require("CONSOLIDATION_VIEW"))], response_model=VgoAssignmentCandidatesOut, summary="Агрегаты ВГО по правилам и компаниям с ручными назначениями", ) diff --git a/api/src/domain/vgo_distribution.py b/api/src/domain/vgo_distribution.py new file mode 100644 index 0000000..874c5bc --- /dev/null +++ b/api/src/domain/vgo_distribution.py @@ -0,0 +1,86 @@ +"""Чистый выбор агрегатов и строк ВГО после поиска встречных сумм.""" + +import uuid +from collections import Counter, defaultdict +from collections.abc import Mapping, Sequence, Set +from dataclasses import dataclass +from decimal import Decimal + +from src.domain.consolidated_osv import ConsolidatedRow + +RowKey = tuple[uuid.UUID, ...] +ZERO = Decimal("0") + + +@dataclass(frozen=True) +class Distribution: + """Результат выбора: расхождения, строки без пары и остаток для ручного разбора.""" + + selected_diff: tuple[int, ...] + auto_rules: Mapping[RowKey, uuid.UUID] + unassigned: frozenset[RowKey] + used_ids: frozenset[uuid.UUID] + + +@dataclass(frozen=True) +class DistributionInput: + rows: Sequence[ConsolidatedRow] + exact_ids: Set[uuid.UUID] + diff_ids: Sequence[Set[uuid.UUID]] + manual_keys: Set[RowKey] + owners: Mapping[uuid.UUID, Set[uuid.UUID]] + members: Set[uuid.UUID] + + +def account_owners( + selections: Sequence[tuple[uuid.UUID, Set[uuid.UUID]]], +) -> dict[uuid.UUID, frozenset[uuid.UUID]]: + """Для каждого раскрытого дочернего счёта найти все правила, в которые он входит.""" + owners: defaultdict[uuid.UUID, set[uuid.UUID]] = defaultdict(set) + for rule_id, account_ids in selections: + for account_id in account_ids: + owners[account_id].add(rule_id) + return {account_id: frozenset(rule_ids) for account_id, rule_ids in owners.items()} + + +def select_exact_aggregates( + used_ids: Set[uuid.UUID], candidate_ids: Sequence[Set[uuid.UUID]] +) -> tuple[tuple[int, ...], frozenset[uuid.UUID]]: + """Закрепить точные агрегаты по порядку правил без повторного использования строк.""" + used = set(used_ids) + selected = [] + for index, ids in enumerate(candidate_ids): + if ids and used.isdisjoint(ids): + selected.append(index) + used.update(ids) + return tuple(selected), frozenset(used) + + +def distribute_vgo_rows(data: DistributionInput) -> Distribution: + """Выбрать непересекающиеся расхождения, затем однозначные строки без пары и остаток. + + Точные агрегаты уже закреплены по порядку правил. Пересекающиеся расхождения все остаются + нераспределёнными; ручные решения не заменяются автоматическими. + """ + candidates = [(index, ids) for index, ids in enumerate(data.diff_ids) if ids and data.exact_ids.isdisjoint(ids)] + counts = Counter(entry_id for _, ids in candidates for entry_id in ids) + selected = tuple(index for index, ids in candidates if all(counts[entry_id] == 1 for entry_id in ids)) + used = set(data.exact_ids) + for index in selected: + used.update(data.diff_ids[index]) + + auto_rules: dict[RowKey, uuid.UUID] = {} + unassigned: set[RowKey] = set() + for row in data.rows: + if not used.isdisjoint(row.entry_ids): + continue + rule_ids = data.owners.get(row.account_id, frozenset()) if row.account_id is not None else frozenset() + if row.entry_ids not in data.manual_keys and len(rule_ids) == 1 and ( + row.legal_entity_id in data.members + and row.counterparty_id in data.members + and (row.closing_debit not in (None, ZERO) or row.closing_credit not in (None, ZERO)) + ): + auto_rules[row.entry_ids] = next(iter(rule_ids)) + elif rule_ids or row.entry_ids in data.manual_keys: + unassigned.add(row.entry_ids) + return Distribution(selected, auto_rules, frozenset(unassigned), frozenset(used)) diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index 8d15cb5..f5a439e 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -42,7 +42,9 @@ class VgoAssignment(UUIDPrimaryMixin, Base): ) statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False) osv_entry_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("osv_entry.id", name="fk_vgo_assignment_entry"), nullable=False + UUID(as_uuid=True), + ForeignKey("osv_entry.id", name="fk_vgo_assignment_entry", ondelete="CASCADE"), + nullable=False, ) rule_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("elimination_rule.id", name="fk_vgo_assignment_rule") diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 15e1729..bd12f24 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -425,16 +425,37 @@ class EliminationPairsOut(BaseModel): return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) -class VgoAssignmentRowOut(ConsolidatedRowOut): - """Строка свода с данными ручного решения, если оно есть.""" +class VgoAssignmentRowOut(BaseModel): + """Поля строки свода, необходимые для распределения ВГО.""" - osv_entry_id: uuid.UUID | None = None + entry_ids: list[uuid.UUID] + legal_entity_id: uuid.UUID + legal_entity_name: str + counterparty_id: uuid.UUID | None + counterparty_name: str | None + account_code: str | None + period: str + opening_debit: Decimal | None + opening_credit: Decimal | None + turnover_debit: Decimal | None + turnover_credit: Decimal | None + closing_debit: Decimal | None + closing_credit: Decimal | None action: Literal["ASSIGN", "UNASSIGNED"] | None = None is_exclude: bool = True + is_auto_assigned: bool = False + unassigned_reason: Literal["PARTIAL_MANUAL_ASSIGNMENT"] | None = None + + @field_validator( + "opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit" + ) + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) class VgoAssignmentGroupOut(BaseModel): - """Принятые агрегаты и ручные строки одного правила и направленной пары компаний.""" + """Принятые агрегаты и строки без встречной пары одного правила и пары компаний.""" rule_id: uuid.UUID rule_no: int diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py index 3e5dfd2..e38a2d0 100644 --- a/api/src/services/vgo.py +++ b/api/src/services/vgo.py @@ -14,9 +14,17 @@ from src.common.exceptions import DomainValidationError from src.domain.consolidated_osv import ConsolidatedRow from src.domain.elimination_packing import calculate_rule_totals from src.domain.policy import VisibilityScope +from src.domain.vgo_distribution import ( + Distribution, + DistributionInput, + account_owners, + distribute_vgo_rows, + select_exact_aggregates, +) from src.models.accounting import EliminationRule from src.models.enums import AuditAction, AuditSection, StatementType from src.models.kfo import VgoAssignment +from src.repositories import directories as directories_repo from src.repositories import elimination_rules as elimination_rules_repo from src.repositories import vgo_assignments as vgo_assignments_repo from src.schemas.consolidations import ( @@ -31,7 +39,7 @@ from src.schemas.consolidations import ( ) from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService from src.services.consolidation_guard import lock_editable_version -from src.services.intercompany_matrix import IntercompanyMatrixService +from src.services.intercompany_matrix import IntercompanyMatrixService, _make_account_tree ZERO = Decimal("0") GroupKey = tuple[uuid.UUID, uuid.UUID, uuid.UUID] @@ -60,9 +68,22 @@ def _ids_from_aggregate(aggregate: EliminationMatchedPairOut) -> set[uuid.UUID]: def _assignment_row(row: ConsolidatedRow, assignment: VgoAssignment | None = None) -> VgoAssignmentRowOut: - output = VgoAssignmentRowOut.model_validate(row, from_attributes=True) + output = VgoAssignmentRowOut( + entry_ids=list(row.entry_ids), + legal_entity_id=row.legal_entity_id, + legal_entity_name=row.legal_entity_name, + counterparty_id=row.counterparty_id, + counterparty_name=row.counterparty_entity_name or row.counterparty_name, + account_code=row.account_code, + period=row.period, + opening_debit=row.opening_debit, + opening_credit=row.opening_credit, + turnover_debit=row.turnover_debit, + turnover_credit=row.turnover_credit, + closing_debit=row.closing_debit, + closing_credit=row.closing_credit, + ) if assignment is not None: - output.osv_entry_id = assignment.osv_entry_id if len(row.entry_ids) == 1 else None output.action = "ASSIGN" if assignment.action == "ASSIGN" else "UNASSIGNED" output.is_exclude = assignment.is_exclude return output @@ -88,6 +109,7 @@ class _ManualResult: groups: dict[GroupKey, list[ManualItem]] by_row: dict[tuple[uuid.UUID, ...], VgoAssignment] assigned_ids: set[uuid.UUID] + partial_rows: set[tuple[uuid.UUID, ...]] def _manual_assignments( @@ -98,15 +120,15 @@ def _manual_assignments( groups: defaultdict[GroupKey, list[ManualItem]] = defaultdict(list) by_row: dict[tuple[uuid.UUID, ...], VgoAssignment] = {} assigned_ids: set[uuid.UUID] = set() + partial_rows: set[tuple[uuid.UUID, ...]] = set() for row in rows: found = [by_entry[entry_id] for entry_id in row.entry_ids if entry_id in by_entry] if not found: continue if len(found) != len(row.entry_ids): - raise DomainValidationError( - "AMBIGUOUS_VGO_ASSIGNMENT", - "Для части исходных строк сводной ОСВ есть ручное назначение, а для остальных нет", - ) + by_row[row.entry_ids] = found[0] + partial_rows.add(row.entry_ids) + continue assignment = found[0] if any( (item.action, item.rule_id, item.is_exclude) @@ -123,23 +145,46 @@ def _manual_assignments( key, amount_a, amount_b = _manual_key_amount(row, rule) groups[key].append((_assignment_row(row, assignment), amount_a, amount_b)) assigned_ids.update(row.entry_ids) - return _ManualResult(groups, by_row, assigned_ids) + return _ManualResult(groups, by_row, assigned_ids, partial_rows) + + +def _assign_unpaired( + rows: Sequence[ConsolidatedRow], + manual: _ManualResult, + auto_rules: Mapping[tuple[uuid.UUID, ...], uuid.UUID], + rules: Mapping[uuid.UUID, EliminationRule], +) -> None: + """Отнести оставшиеся без агрегата строки к единственному правилу их счёта.""" + for row in rows: + rule_id = auto_rules.get(row.entry_ids) + if rule_id is None: + continue + rule = rules[rule_id] + key, amount_a, amount_b = _manual_key_amount(row, rule) + output = _assignment_row(row) + output.action = "ASSIGN" + output.is_exclude = False + output.is_auto_assigned = True + manual.groups.setdefault(key, []).append((output, amount_a, amount_b)) + manual.assigned_ids.update(row.entry_ids) def _unassigned_rows( - all_rows: Sequence[ConsolidatedRow], - candidates: Sequence[ConsolidatedRow], + rows: Sequence[ConsolidatedRow], manual: _ManualResult, - used_ids: set[uuid.UUID], + distribution: Distribution, ) -> list[VgoAssignmentRowOut]: - rows = [*candidates] - candidate_keys = {row.entry_ids for row in candidates} - rows.extend(row for row in all_rows if row.entry_ids not in candidate_keys and row.entry_ids in manual.by_row) - return [ - _assignment_row(row, manual.by_row.get(row.entry_ids)) - for row in rows - if used_ids.isdisjoint(row.entry_ids) and manual.assigned_ids.isdisjoint(row.entry_ids) - ] + result = [] + for row in rows: + if row.entry_ids not in distribution.unassigned or not manual.assigned_ids.isdisjoint(row.entry_ids): + continue + if row.entry_ids in manual.partial_rows: + item = _assignment_row(row) + item.unassigned_reason = "PARTIAL_MANUAL_ASSIGNMENT" + else: + item = _assignment_row(row, manual.by_row.get(row.entry_ids)) + result.append(item) + return result @dataclass @@ -153,21 +198,15 @@ class _AggregateSearchResult: used_ids: set[uuid.UUID] = field(default_factory=set) -def _select_diff_aggregates(search: _AggregateSearchResult) -> dict[GroupKey, list[EliminationMatchedPairOut]]: - """Принять только расхождения с единственным вариантом для набора исходных строк.""" - by_ids: defaultdict[tuple[uuid.UUID, ...], list[EliminationMatchedPairOut]] = defaultdict(list) - for aggregate in search.diff_aggregates: - ids = _ids_from_aggregate(aggregate) - if ids and search.used_ids.isdisjoint(ids): - by_ids[tuple(sorted(ids))].append(aggregate) - +def _select_diff_aggregates( + search: _AggregateSearchResult, distribution: Distribution +) -> dict[GroupKey, list[EliminationMatchedPairOut]]: + """Сгруппировать выбранные доменной функцией расхождения.""" diff_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = defaultdict(list) - for options in by_ids.values(): - if len(options) == 1: - aggregate = options[0] - key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) - diff_aggregates[key].append(aggregate) - search.used_ids.update(_ids_from_aggregate(aggregate)) + for index in distribution.selected_diff: + aggregate = search.diff_aggregates[index] + key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) + diff_aggregates[key].append(aggregate) return diff_aggregates @@ -190,8 +229,8 @@ def _build_groups( manual_rows = manual.get(key, []) amount_a = sum((aggregate.amount_a for aggregate in aggregates), ZERO) amount_b = sum((aggregate.amount_b for aggregate in aggregates), ZERO) - amount_a += sum((amount_a for row, amount_a, _ in manual_rows if not row.is_exclude), ZERO) - amount_b += sum((amount_b for row, _, amount_b in manual_rows if not row.is_exclude), ZERO) + amount_a += sum((item_amount_a for _, item_amount_a, _ in manual_rows), ZERO) + amount_b += sum((item_amount_b for _, _, item_amount_b in manual_rows), ZERO) totals = calculate_rule_totals(amount_a, amount_b, manual=False) groups.append( VgoAssignmentGroupOut( @@ -226,6 +265,20 @@ class VgoService: self._matrix_service = matrix_service self.session = session + async def _rule_accounts( + self, osv_result: ConsolidatedOsvBuildResult, rules: Sequence[EliminationRule] + ) -> dict[uuid.UUID, frozenset[uuid.UUID]]: + """Раскрыть дочерние счета и определить их принадлежность правилам.""" + accounts = await directories_repo.list_accounts(self.session) + by_id, children = _make_account_tree(accounts) + selections = [] + for rule in rules: + selection, _ = await self._matrix_service._resolve_preset_selection( + rule.id, osv_result.version, by_id, children + ) + selections.append((rule.id, selection.company_1_account_ids | selection.company_2_account_ids)) + return account_owners(selections) + async def _find_aggregates( self, version_id: uuid.UUID, @@ -235,21 +288,23 @@ class VgoService: ) -> _AggregateSearchResult: """Перебрать правила и компании, сразу закрепляя только точные агрегаты.""" search = _AggregateSearchResult() - for company_a in osv_result.members: - for company_b in osv_result.members: - if company_a == company_b: - continue - for rule in rules: + for rule in rules: + for company_a in osv_result.members: + for company_b in osv_result.members: + if company_a == company_b: + continue free_rows = [row for row in rows if search.used_ids.isdisjoint(row.entry_ids)] result = await self._matrix_service.build_pairs( version_id, rule.id, company_a, company_b, free_rows, osv_result=osv_result ) - for aggregate in result.matched: - ids = _ids_from_aggregate(aggregate) - if search.used_ids.isdisjoint(ids): - search.used_ids.update(ids) - key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) - search.matched_aggregates[key].append(aggregate) + accepted, used_ids = select_exact_aggregates( + search.used_ids, [_ids_from_aggregate(aggregate) for aggregate in result.matched] + ) + search.used_ids.update(used_ids) + for index in accepted: + aggregate = result.matched[index] + key = (aggregate.rule_id, aggregate.company_a_id, aggregate.company_b_id) + search.matched_aggregates[key].append(aggregate) search.diff_aggregates.extend(result.with_discrepancy) return search @@ -257,15 +312,32 @@ class VgoService: """Найти агрегаты и общие для всех правил строки без назначения, не записывая их в БД.""" osv_result = await self._osv_service.build_with_members(version_id) all_rules = await elimination_rules_repo.list_rules(self.session, osv_result.version.elimination_rules_version) - rules = [ - rule for rule in all_rules if rule.statement_type == StatementType.balance and rule.measure == "closing" - ] + rules = sorted( + (rule for rule in all_rules if rule.statement_type == StatementType.balance and rule.measure == "closing"), + key=lambda rule: rule.rule_no, + ) rows = assignment_candidates(osv_result.consolidated.rows) assignments = await vgo_assignments_repo.list_assignments(self.session, version_id, StatementType.balance) manual = _manual_assignments(osv_result.consolidated.rows, assignments, rules) free_rows = [row for row in rows if row.entry_ids not in manual.by_row] + owners = await self._rule_accounts(osv_result, rules) if any(row.account_id is not None for row in rows) else {} search = await self._find_aggregates(version_id, osv_result, rules, free_rows) - diff_aggregates = _select_diff_aggregates(search) + candidate_keys = {row.entry_ids for row in rows} + decision_rows = [*rows] + decision_rows.extend( + row for row in osv_result.consolidated.rows + if row.entry_ids not in candidate_keys and row.entry_ids in manual.by_row + ) + distribution = distribute_vgo_rows(DistributionInput( + rows=decision_rows, + exact_ids=search.used_ids, + diff_ids=[_ids_from_aggregate(aggregate) for aggregate in search.diff_aggregates], + manual_keys=set(manual.by_row), + owners=owners, + members=set(osv_result.members), + )) + diff_aggregates = _select_diff_aggregates(search, distribution) + _assign_unpaired(free_rows, manual, distribution.auto_rules, {rule.id: rule for rule in rules}) names = {company_id: entity.name for company_id, entity in osv_result.members.items()} for row in osv_result.consolidated.rows: @@ -275,7 +347,7 @@ class VgoService: names.setdefault(row.counterparty_id, name) groups = _build_groups(search, diff_aggregates, rules, manual.groups, names) - unassigned = _unassigned_rows(osv_result.consolidated.rows, rows, manual, search.used_ids) + unassigned = _unassigned_rows(decision_rows, manual, distribution) return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned) diff --git a/api/tests/test_api_vgo_assignments.py b/api/tests/test_api_vgo_assignments.py index 141cae3..b12cf51 100644 --- a/api/tests/test_api_vgo_assignments.py +++ b/api/tests/test_api_vgo_assignments.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock import pytest from pydantic import ValidationError -from sqlalchemy import select +from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncSession from src.api.endpoints.consolidations.vgo_assignment import router @@ -15,6 +15,7 @@ from src.common.clock import utc_now from src.common.exceptions import DomainValidationError from src.models.access import AppUser from src.models.accounting import EliminationRule +from src.models.base import SCHEMA from src.models.consolidation import ConsolidationDocument from src.models.directories import RegionalOffice from src.models.enums import ConsolidationStatus, PeriodQuarter @@ -33,7 +34,7 @@ def test_assignment_routes_are_limited_to_requested_actions() -> None: routes = {(method, route.path) for route in router.routes for method in route.methods} prefix = "/consolidations/versions/{version_id}/vgo-assignments" assert routes == { - ("POST", f"{prefix}/distribute"), + ("GET", f"{prefix}/distribute"), ("PUT", f"{prefix}/rule"), ("DELETE", f"{prefix}/rule"), ("PUT", f"{prefix}/exclude"), @@ -166,6 +167,21 @@ async def test_exclusion_creates_missing_assignment(monkeypatch: pytest.MonkeyPa async def test_vgo_assignment_integration(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + constraints = (await seeded.execute( + text( + "SELECT conname, contype::text, confdeltype::text " + "FROM pg_constraint WHERE conrelid = to_regclass(:relation)" + ), + {"relation": f"{SCHEMA}.vgo_assignment"}, + )).all() + names = {name for name, _, _ in constraints} + assert { + "ck_vgo_assignment_action_values", + "ck_vgo_assignment_action_rule_consistency", + "ck_vgo_assignment_rule_not_excluded", + } <= names + assert ("fk_vgo_assignment_entry", "f", "c") in constraints + user = (await seeded.scalars(select(AppUser).where(AppUser.email == USER_EMAIL))).one() office = (await seeded.scalars(select(RegionalOffice).where(RegionalOffice.id == user.regional_office_id))).one() group = await make_company_group(seeded, office=office) @@ -190,6 +206,16 @@ async def test_vgo_assignment_integration(auth_client: AuthClientFactory, seeded )).all()) client = await auth_client(USER_EMAIL) await _check_assignment_actions(client, seeded, version, entry_ids) + distribute_url = f"/consolidations/versions/{version.id}/vgo-assignments/distribute" + view_only = await auth_client("curator_go@armka.local") + distribution = await view_only.get(distribute_url) + assert distribution.status_code == HTTPStatus.OK, distribution.text + assert "match_stored" not in distribution.text + forbidden = await (await auth_client("admin@armka.local")).get(distribute_url) + assert forbidden.status_code == HTTPStatus.FORBIDDEN + await seeded.execute(delete(OsvEntry).where(OsvEntry.id == entry_ids[0])) + assert await seeded.scalar(select(VgoAssignment.id).where(VgoAssignment.osv_entry_id == entry_ids[0])) is None + assert await seeded.scalar(select(VgoAssignment.id).where(VgoAssignment.osv_entry_id == entry_ids[1])) is not None async def _check_assignment_actions(client, seeded, version, entry_ids) -> None: diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py index eefc949..711bf1f 100644 --- a/api/tests/test_vgo.py +++ b/api/tests/test_vgo.py @@ -9,6 +9,7 @@ import pytest from src.common.exceptions import DomainValidationError from src.domain.consolidated_osv import CounterpartyType +from src.domain.vgo_distribution import DistributionInput, distribute_vgo_rows from src.models.enums import StatementType from src.schemas.consolidations import EliminationMatchedPairOut from src.services import vgo @@ -43,6 +44,7 @@ def _row(entry_id: uuid.UUID, company_a: uuid.UUID, company_b: uuid.UUID, index: legal_entity_id=company_a, legal_entity_name="A", legal_entity_inn="1234567890", + account_id=None, account_code="76", account_root="76", period="2026 Q1", @@ -70,6 +72,76 @@ def _members(company_a: uuid.UUID, company_b: uuid.UUID) -> dict[uuid.UUID, Simp return {company_a: SimpleNamespace(name="A"), company_b: SimpleNamespace(name="B")} +def test_partially_overlapping_diff_aggregates_remain_unassigned() -> None: + rule_6, rule_7, company_a, company_b, a_76, b_60, b_76, extra_a, extra_b = ( + uuid.uuid4() for _ in range(9) + ) + first = _pair(rule_6, company_a, company_b, a_76, b_60, amount_b="9") + second = _pair(rule_7, company_a, company_b, a_76, b_76, amount_b="9") + independent = _pair(rule_6, company_a, company_b, extra_a, extra_b, amount_b="9") + distribution = distribute_vgo_rows(DistributionInput( + rows=[], exact_ids=set(), + diff_ids=[vgo._ids_from_aggregate(pair) for pair in (first, second, independent)], + manual_keys=set(), owners={}, members=set(), + )) + + assert distribution.selected_diff == (2,) + assert distribution.used_ids == {extra_a, extra_b} + + +async def test_unpaired_row_with_unique_rule_account_is_auto_assigned(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, rule_1, rule_2, unique_account, shared_account = ( + uuid.uuid4() for _ in range(7) + ) + unique_row = _row(uuid.uuid4(), company_a, company_b, 1) + unique_row.account_id = unique_account + unique_row.closing_debit = Decimal("7") + shared_row = _row(uuid.uuid4(), company_a, company_b, 2) + shared_row.account_id = shared_account + shared_row.closing_debit = Decimal("3") + outside_row = _row(uuid.uuid4(), company_a, company_b, 3) + outside_row.account_id = uuid.uuid4() + rules = [ + SimpleNamespace( + id=rule_id, rule_no=number, statement_type=StatementType.balance, measure="closing", + side_1="D", article_1_line="A", article_2_line="B", + ) + for number, rule_id in enumerate((rule_1, rule_2), start=1) + ] + osv_result = SimpleNamespace( + version=SimpleNamespace(id=version_id, elimination_rules_version=2), + consolidated=SimpleNamespace(rows=[unique_row, shared_row, outside_row]), + members=_members(company_a, company_b), + ) + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules)) + monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[])) + monkeypatch.setattr(vgo.directories_repo, "list_accounts", AsyncMock(return_value=[])) + + async def resolve(rule_id: uuid.UUID, *_args: object) -> tuple[SimpleNamespace, SimpleNamespace]: + account_ids = {shared_account, unique_account} if rule_id == rule_1 else {shared_account} + selection = SimpleNamespace(company_1_account_ids=frozenset(account_ids), company_2_account_ids=frozenset()) + return selection, rules[0] + + matrix = SimpleNamespace( + _resolve_preset_selection=AsyncMock(side_effect=resolve), + build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[])), + ) + service = vgo.VgoService( + AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix + ) + + response = await service.assignment_candidates(version_id) + + assert len(response.groups) == 1 + assert response.groups[0].rule_id == rule_1 + assert response.groups[0].amount_a == Decimal("7.00") + assert response.groups[0].manual[0].entry_ids == [unique_row.entry_ids[0]] + assert response.groups[0].manual[0].is_auto_assigned is True + assert response.groups[0].manual[0].is_exclude is False + assert [row.entry_ids for row in response.unassigned] == [[shared_row.entry_ids[0]]] + assert "match_stored" not in response.unassigned[0].model_dump() + + async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest.MonkeyPatch) -> None: version_id, company_a, company_b, rule_1, rule_2 = (uuid.uuid4() for _ in range(5)) entry_ids = [uuid.uuid4() for _ in range(11)] @@ -89,7 +161,7 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest article_1_line="Статья A2", article_2_line="Статья B2", ), ] - monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=rules)) + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=list(reversed(rules)))) monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[])) exact_1 = _pair(rule_1, company_a, company_b, entry_ids[0], entry_ids[1]) exact_1_more = _pair(rule_1, company_a, company_b, entry_ids[9], entry_ids[10]) @@ -118,7 +190,18 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), SimpleNamespace(build_pairs=AsyncMock(side_effect=build_pairs)), ) + account_id = uuid.uuid4() + for row in rows: + row.account_id = account_id + service._rule_accounts = AsyncMock(return_value={account_id: frozenset({rule_1, rule_2})}) response = await service.assignment_candidates(version_id) + calls = service._matrix_service.build_pairs.await_args_list + assert [(call.args[1], call.args[2], call.args[3]) for call in calls] == [ + (rule_1, company_a, company_b), + (rule_1, company_b, company_a), + (rule_2, company_a, company_b), + (rule_2, company_b, company_a), + ] groups = response.groups assert [(group.rule_id, group.company_a_id, group.company_b_id) for group in groups] == [ @@ -196,37 +279,44 @@ async def test_manual_assignments_override_auto_and_show_exclusion_flag(monkeypa company_b, ) assert (response.groups[0].amount_a, response.groups[0].amount_b) == (Decimal("30.00"), Decimal("0.00")) - assert [(row.osv_entry_id, row.is_exclude) for row in response.groups[0].manual] == [ - (entry_ids[0], False), - (entry_ids[1], False), + assert [(row.entry_ids, row.is_exclude) for row in response.groups[0].manual] == [ + ([entry_ids[0]], False), + ([entry_ids[1]], False), ] assert "manual" in response.model_dump()["groups"][0] assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [ ([entry_ids[2]], "UNASSIGNED", True), - ([entry_ids[3]], None, True), ] -async def test_glued_row_requires_assignments_for_all_source_entries(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_partial_manual_assignment_stays_unassigned_and_is_not_searched(monkeypatch: pytest.MonkeyPatch) -> None: version_id, company_a, company_b, rule_id, entry_a, entry_b = (uuid.uuid4() for _ in range(6)) row = _row(entry_a, company_a, company_b, 1) row.entry_ids = (entry_a, entry_b) + free_row = _row(uuid.uuid4(), company_a, company_b, 2) osv_result = SimpleNamespace( version=SimpleNamespace(elimination_rules_version=2), - consolidated=SimpleNamespace(rows=[row]), + consolidated=SimpleNamespace(rows=[row, free_row]), members=_members(company_a, company_b), ) assignment = SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False) - monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[])) + rule = SimpleNamespace(id=rule_id, rule_no=1, statement_type=StatementType.balance, measure="closing") + monkeypatch.setattr(vgo.elimination_rules_repo, "list_rules", AsyncMock(return_value=[rule])) monkeypatch.setattr(vgo.vgo_assignments_repo, "list_assignments", AsyncMock(return_value=[assignment])) + matrix = SimpleNamespace(build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[]))) service = vgo.VgoService( AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), - SimpleNamespace(build_pairs=AsyncMock()), + matrix, ) - with pytest.raises(DomainValidationError, match="для остальных нет"): - await service.assignment_candidates(version_id) + response = await service.assignment_candidates(version_id) + + assert response.groups == [] + assert [item.entry_ids for item in response.unassigned] == [[entry_a, entry_b]] + assert response.unassigned[0].unassigned_reason == "PARTIAL_MANUAL_ASSIGNMENT" + assert response.unassigned[0].action is None + assert all(call.args[4] == [free_row] for call in matrix.build_pairs.await_args_list) async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pytest.MonkeyPatch) -> None: @@ -261,7 +351,7 @@ async def test_glued_row_with_same_assignments_is_counted_once(monkeypatch: pyte assert response.groups[0].amount_a == Decimal("10.00") assert len(response.groups[0].manual) == 1 assert response.groups[0].manual[0].entry_ids == [entry_a, entry_b] - assert response.groups[0].manual[0].osv_entry_id is None + assert "osv_entry_id" not in response.groups[0].manual[0].model_dump() assert response.unassigned == [] assert all(call.args[4] == [] for call in matrix_service.build_pairs.await_args_list) -- 2.47.2 From c62f9b2e394c1cc2911c8ee3a3350c8edb0899e1 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Mon, 2 Nov 2026 18:12:32 +0300 Subject: [PATCH 08/10] =?UTF-8?q?AURORA-1540:=20API=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=20=D0=92=D0=93=D0=9E,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0011_dbml_v5_24.py | 14 +- .../versions/0012_vgo_rule_not_excluded.py | 29 ---- .../versions/0013_vgo_entry_cascade.py | 36 ----- api/alembic/versions/0014_vgo_check_names.py | 28 ---- api/scripts/dbml_parity.py | 2 +- api/src/api/dependencies/consolidations.py | 19 ++- .../consolidations/vgo_assignment.py | 26 +++- api/src/repositories/corrections.py | 26 +--- api/src/schemas/consolidations.py | 41 +++++ api/src/schemas/corrections.py | 16 ++ api/src/services/correction_invalidation.py | 68 ++++++++ api/src/services/correction_source.py | 102 +++++------- api/src/services/corrections.py | 14 +- api/src/services/vgo.py | 146 +++++++++++++++++- api/tests/test_api_corrections.py | 5 + api/tests/test_api_corrections_mutations.py | 2 + api/tests/test_api_vgo_assignments.py | 22 +++ api/tests/test_correction_invalidation.py | 65 ++++++++ api/tests/test_correction_source.py | 110 +++++++------ api/tests/test_corrections_export.py | 3 + api/tests/test_corrections_review_fixes.py | 30 ++++ api/tests/test_migrations.py | 5 +- api/tests/test_openapi_routes.py | 4 +- api/tests/test_vgo.py | 20 ++- .../corrections-api-stage-one/design.md | 3 +- .../specs/corrections-api/spec.md | 10 ++ .../corrections-api-stage-one/tasks.md | 3 +- 27 files changed, 597 insertions(+), 252 deletions(-) delete mode 100644 api/alembic/versions/0012_vgo_rule_not_excluded.py delete mode 100644 api/alembic/versions/0013_vgo_entry_cascade.py delete mode 100644 api/alembic/versions/0014_vgo_check_names.py create mode 100644 api/src/services/correction_invalidation.py create mode 100644 api/tests/test_correction_invalidation.py create mode 100644 api/tests/test_corrections_review_fixes.py diff --git a/api/alembic/versions/0011_dbml_v5_24.py b/api/alembic/versions/0011_dbml_v5_24.py index 7899223..6d87790 100644 --- a/api/alembic/versions/0011_dbml_v5_24.py +++ b/api/alembic/versions/0011_dbml_v5_24.py @@ -25,7 +25,7 @@ def upgrade() -> None: sa.Column("osv_entry_id", postgresql.UUID(as_uuid=True), nullable=False), sa.Column("rule_id", postgresql.UUID(as_uuid=True)), sa.Column("action", sa.String(), nullable=False), - sa.Column("is_exclude", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("is_exclude", sa.Boolean(), server_default=sa.text("true"), nullable=False), sa.Column("created_by", postgresql.UUID(as_uuid=True)), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), sa.Column("updated_by", postgresql.UUID(as_uuid=True)), @@ -33,15 +33,21 @@ def upgrade() -> None: sa.ForeignKeyConstraint( ["consolidation_version_id"], [f"{SCHEMA}.consolidation_version.id"], name="fk_vgo_assignment_version" ), - sa.ForeignKeyConstraint(["osv_entry_id"], [f"{SCHEMA}.osv_entry.id"], name="fk_vgo_assignment_entry"), + sa.ForeignKeyConstraint( + ["osv_entry_id"], + [f"{SCHEMA}.osv_entry.id"], + name="fk_vgo_assignment_entry", + ondelete="CASCADE", + ), sa.ForeignKeyConstraint(["rule_id"], [f"{SCHEMA}.elimination_rule.id"], name="fk_vgo_assignment_rule"), sa.ForeignKeyConstraint(["created_by"], [f"{SCHEMA}.app_user.id"], name="fk_vgo_assignment_created_by"), sa.ForeignKeyConstraint(["updated_by"], [f"{SCHEMA}.app_user.id"], name="fk_vgo_assignment_updated_by"), - sa.CheckConstraint("action IN ('ASSIGN', 'UNASSIGNED')", name="ck_vgo_assignment_action_values"), + sa.CheckConstraint("action IN ('ASSIGN', 'UNASSIGNED')", name="action_values"), sa.CheckConstraint( "(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)", - name="ck_vgo_assignment_action_rule_consistency", + name="action_rule_consistency", ), + sa.CheckConstraint("rule_id IS NULL OR NOT is_exclude", name="rule_not_excluded"), schema=SCHEMA, ) op.create_index( diff --git a/api/alembic/versions/0012_vgo_rule_not_excluded.py b/api/alembic/versions/0012_vgo_rule_not_excluded.py deleted file mode 100644 index 260969f..0000000 --- a/api/alembic/versions/0012_vgo_rule_not_excluded.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Запретить исключение из КФО строк с назначенным правилом ВГО.""" - -import sqlalchemy as sa - -from alembic import op -from src.models.base import SCHEMA - -revision = "0012_vgo_rule_not_excluded" -down_revision = "0011_vgo_assignment" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.execute(sa.text(f'UPDATE "{SCHEMA}".vgo_assignment SET is_exclude = false WHERE rule_id IS NOT NULL')) - op.alter_column("vgo_assignment", "is_exclude", server_default=sa.text("true"), schema=SCHEMA) - op.create_check_constraint( - "ck_vgo_assignment_rule_not_excluded", - "vgo_assignment", - "rule_id IS NULL OR NOT is_exclude", - schema=SCHEMA, - ) - - -def downgrade() -> None: - op.alter_column("vgo_assignment", "is_exclude", server_default=sa.text("false"), schema=SCHEMA) - op.drop_constraint( - "ck_vgo_assignment_rule_not_excluded", "vgo_assignment", type_="check", schema=SCHEMA - ) diff --git a/api/alembic/versions/0013_vgo_entry_cascade.py b/api/alembic/versions/0013_vgo_entry_cascade.py deleted file mode 100644 index c9e1c5d..0000000 --- a/api/alembic/versions/0013_vgo_entry_cascade.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Удалять назначения ВГО вместе с исходной строкой ОСВ.""" - -from alembic import op -from src.models.base import SCHEMA - -revision = "0013_vgo_entry_cascade" -down_revision = "0012_vgo_rule_not_excluded" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.drop_constraint("fk_vgo_assignment_entry", "vgo_assignment", type_="foreignkey", schema=SCHEMA) - op.create_foreign_key( - "fk_vgo_assignment_entry", - "vgo_assignment", - "osv_entry", - ["osv_entry_id"], - ["id"], - source_schema=SCHEMA, - referent_schema=SCHEMA, - ondelete="CASCADE", - ) - - -def downgrade() -> None: - op.drop_constraint("fk_vgo_assignment_entry", "vgo_assignment", type_="foreignkey", schema=SCHEMA) - op.create_foreign_key( - "fk_vgo_assignment_entry", - "vgo_assignment", - "osv_entry", - ["osv_entry_id"], - ["id"], - source_schema=SCHEMA, - referent_schema=SCHEMA, - ) diff --git a/api/alembic/versions/0014_vgo_check_names.py b/api/alembic/versions/0014_vgo_check_names.py deleted file mode 100644 index 99b90df..0000000 --- a/api/alembic/versions/0014_vgo_check_names.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Привести имена CHECK vgo_assignment к naming convention ORM.""" - -import sqlalchemy as sa - -from alembic import op -from src.models.base import SCHEMA - -revision = "0014_vgo_check_names" -down_revision = "0013_vgo_entry_cascade" -branch_labels = None -depends_on = None - -CONSTRAINTS = ("action_values", "action_rule_consistency", "rule_not_excluded") - - -def _rename(*, backwards: bool) -> None: - for suffix in CONSTRAINTS: - name = f"ck_vgo_assignment_{suffix}" - old, new = (name, f"ck_vgo_assignment_{name}") if backwards else (f"ck_vgo_assignment_{name}", name) - op.execute(sa.text(f'ALTER TABLE "{SCHEMA}".vgo_assignment RENAME CONSTRAINT "{old}" TO "{new}"')) - - -def upgrade() -> None: - _rename(backwards=False) - - -def downgrade() -> None: - _rename(backwards=True) diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 92d4a3c..067a03a 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -401,7 +401,7 @@ def _index_problems(orm_table: Table, dbml_table: DbmlTable) -> list[str]: def _check_problems(orm_table: Table) -> list[str]: """CHECK-ограничения: в DBML их нет (типы и enum'ы — нативные), любое CHECK в ORM — лишнее.""" if orm_table.name == "vgo_assignment": - # Временная DBML v5.24 отражает миграцию 0011; pydbml не представляет её два CHECK-ограничения. + # DBML v5.24 отражает миграцию 0011; pydbml не представляет её три CHECK-ограничения. return [] return [ missing(f"{orm_table.name}: CHECK {constraint.sqltext}", DBML) diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index 5400453..cfb637d 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -13,6 +13,7 @@ from src.services.consolidated_osv import ConsolidatedOsvService from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService +from src.services.correction_invalidation import CorrectionInvalidationService from src.services.correction_source import CorrectionAggregateSource, VgoCorrectionSource from src.services.corrections import CorrectionService from src.services.counterparty_mapping import CounterpartyMappingService @@ -112,11 +113,9 @@ def get_vgo_service( VgoServiceDep = Annotated[VgoService, Depends(get_vgo_service)] -def get_correction_source( - session: SessionDep, vgo_service: VgoServiceDep, osv_service: ConsolidatedOsvServiceDep -) -> CorrectionAggregateSource: +def get_correction_source(session: SessionDep, vgo_service: VgoServiceDep) -> CorrectionAggregateSource: """Агрегаты из готового автоматического и ручного разнесения ОСВ.""" - return VgoCorrectionSource(session, vgo_service, osv_service) + return VgoCorrectionSource(session, vgo_service) CorrectionSourceDep = Annotated[CorrectionAggregateSource, Depends(get_correction_source)] @@ -135,6 +134,18 @@ def get_correction_service( CorrectionServiceDep = Annotated[CorrectionService, Depends(get_correction_service)] +def get_correction_invalidation_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> CorrectionInvalidationService: + return CorrectionInvalidationService(session, audit) + + +CorrectionInvalidationServiceDep = Annotated[ + CorrectionInvalidationService, Depends(get_correction_invalidation_service) +] + + def get_vgo_assignment_crud_service( session: SessionDep, audit: Annotated[RequestAudit, Depends(get_request_audit)], diff --git a/api/src/api/endpoints/consolidations/vgo_assignment.py b/api/src/api/endpoints/consolidations/vgo_assignment.py index 8c3d41e..4e09420 100644 --- a/api/src/api/endpoints/consolidations/vgo_assignment.py +++ b/api/src/api/endpoints/consolidations/vgo_assignment.py @@ -5,7 +5,11 @@ import uuid from fastapi import APIRouter, Depends from src.api.dependencies.auth import require -from src.api.dependencies.consolidations import VgoAssignmentCrudServiceDep, VgoServiceDep +from src.api.dependencies.consolidations import ( + CorrectionInvalidationServiceDep, + VgoAssignmentCrudServiceDep, + VgoServiceDep, +) from src.schemas.consolidations import ( VgoAssignmentAttachRuleIn, VgoAssignmentCandidatesOut, @@ -35,10 +39,16 @@ async def distribute_articles(version_id: uuid.UUID, service: VgoServiceDep) -> summary="Привязать выбранные строки ОСВ к правилу ВГО", ) async def attach_rule( - version_id: uuid.UUID, data: VgoAssignmentAttachRuleIn, service: VgoAssignmentCrudServiceDep + version_id: uuid.UUID, + data: VgoAssignmentAttachRuleIn, + service: VgoAssignmentCrudServiceDep, + corrections: CorrectionInvalidationServiceDep, ) -> list[VgoAssignmentOut]: """Обновляет назначения для указанных ID и создаёт недостающие записи.""" - return await service.attach_rule(version_id, data) + pairs = await corrections.affected_pairs(data.entry_ids) + result = await service.attach_rule(version_id, data) + await corrections.mark_draft(version_id, pairs) + return result @router.delete( @@ -48,10 +58,16 @@ async def attach_rule( summary="Снять назначение правила ВГО с выбранных строк ОСВ", ) async def detach_rule( - version_id: uuid.UUID, data: VgoAssignmentDetachRuleIn, service: VgoAssignmentCrudServiceDep + version_id: uuid.UUID, + data: VgoAssignmentDetachRuleIn, + service: VgoAssignmentCrudServiceDep, + corrections: CorrectionInvalidationServiceDep, ) -> list[VgoAssignmentOut]: """Оставляет или создаёт записи без правила с is_exclude=true.""" - return await service.detach_rule(version_id, data) + pairs = await corrections.affected_pairs(data.entry_ids) + result = await service.detach_rule(version_id, data) + await corrections.mark_draft(version_id, pairs) + return result @router.put( diff --git a/api/src/repositories/corrections.py b/api/src/repositories/corrections.py index ff9c322..e2d3aec 100644 --- a/api/src/repositories/corrections.py +++ b/api/src/repositories/corrections.py @@ -1,39 +1,17 @@ -"""Хранение решений ВГО; транзакция и проверка видимости принадлежат сервису.""" +"""Хранение корректировок; транзакция и проверка видимости принадлежат сервису.""" import uuid from collections.abc import Collection -from sqlalchemy import Boolean, Column, DateTime, MetaData, String, Table, delete, select -from sqlalchemy.dialects import postgresql +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from src.models.base import SCHEMA from src.models.consolidation import ConsolidationDocument from src.models.enums import CorrectionStatus, StatementType from src.models.files import Document from src.models.kfo import Correction from src.models.statements import FinancialStatementLine, FinancialStatementMeta -vgo_assignment = Table( - "vgo_assignment", - MetaData(schema=SCHEMA), - Column("id", postgresql.UUID(as_uuid=True), primary_key=True), - Column("consolidation_version_id", postgresql.UUID(as_uuid=True), nullable=False), - Column( - "statement_type", - postgresql.ENUM("balance", "income", name="statement_type", schema=SCHEMA, create_type=False), - nullable=False, - ), - Column("osv_entry_id", postgresql.UUID(as_uuid=True), nullable=False), - Column("rule_id", postgresql.UUID(as_uuid=True)), - Column("action", String, nullable=False), - Column("is_exclude", Boolean, nullable=False), - Column("created_by", postgresql.UUID(as_uuid=True)), - Column("created_at", DateTime(timezone=True), nullable=False), - Column("updated_by", postgresql.UUID(as_uuid=True)), - Column("updated_at", DateTime(timezone=True)), -) - async def active_corrections( session: AsyncSession, version_id: uuid.UUID, statement_type: StatementType diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index bd12f24..7702cfc 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -454,6 +454,24 @@ class VgoAssignmentRowOut(BaseModel): return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) +class VgoAssignmentPositionOut(BaseModel): + """Готовая позиция агрегата с разрешённой стороной и суммой.""" + + entry_ids: list[uuid.UUID] + legal_entity_id: uuid.UUID + counterparty_id: uuid.UUID + account_code: str + side: Literal["D", "K"] + amount: Decimal + action: Literal["ASSIGN"] | None = None + candidate_rule_ids: list[uuid.UUID] = Field(default_factory=list) + + @field_validator("amount") + @classmethod + def _two_decimals(cls, value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) + + class VgoAssignmentGroupOut(BaseModel): """Принятые агрегаты и строки без встречной пары одного правила и пары компаний.""" @@ -473,6 +491,7 @@ class VgoAssignmentGroupOut(BaseModel): matched: list[EliminationMatchedPairOut] = Field(default_factory=list) with_discrepancy: list[EliminationMatchedPairOut] = Field(default_factory=list) manual: list[VgoAssignmentRowOut] = Field(default_factory=list, exclude_if=lambda rows: not rows) + positions: list[VgoAssignmentPositionOut] = Field(default_factory=list) @field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent") @classmethod @@ -485,6 +504,28 @@ class VgoAssignmentCandidatesOut(BaseModel): groups: list[VgoAssignmentGroupOut] unassigned: list[VgoAssignmentRowOut] + unallocated: list["VgoUnallocatedPositionOut"] = Field(default_factory=list) + + +class VgoUnallocatedPositionOut(BaseModel): + """Готовая позиция, для которой распределение не выбрало правило.""" + + entry_ids: list[uuid.UUID] + legal_entity_id: uuid.UUID + legal_entity_name: str + counterparty_id: uuid.UUID + counterparty_name: str + account_code: str + side: Literal["D", "K"] + amount: Decimal + candidate_rule_ids: list[uuid.UUID] + reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] + excluded: bool + + @field_validator("amount") + @classmethod + def _unallocated_amount_two_decimals(cls, value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) class VgoAssignmentAttachRuleIn(BaseModel): diff --git a/api/src/schemas/corrections.py b/api/src/schemas/corrections.py index 4e6c399..5ded9f0 100644 --- a/api/src/schemas/corrections.py +++ b/api/src/schemas/corrections.py @@ -41,6 +41,21 @@ class CorrectionEntryOut(BaseModel): account_code: str side: Side amount: Money + decision: Literal["AUTO", "ASSIGN"] + candidate_rule_ids: list[uuid.UUID] + + +class UnallocatedEntryOut(BaseModel): + entry_ids: list[uuid.UUID] + owner: RefOut + counterparty: RefOut + account_code: str + line_code: str | None + side: Side + amount: Money + candidate_rule_ids: list[uuid.UUID] + reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] + excluded: bool class AggregateSideOut(BaseModel): @@ -117,6 +132,7 @@ class CorrectionTableOut(BaseModel): read_only_reason: Literal["VERSION_NOT_EDITABLE", "LOCK_REQUIRED", "LOCKED_BY_OTHER"] | None rules: list[CorrectionRuleOut] aggregates: list[CorrectionAggregateOut] + unallocated: list[UnallocatedEntryOut] totals: CorrectionTotalsOut diff --git a/api/src/services/correction_invalidation.py b/api/src/services/correction_invalidation.py new file mode 100644 index 0000000..36da953 --- /dev/null +++ b/api/src/services/correction_invalidation.py @@ -0,0 +1,68 @@ +"""Консервативная инвалидация корректировок после ручного назначения строк ОСВ.""" + +import uuid +from collections.abc import Collection + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.models.enums import AuditAction, AuditSection, CorrectionStatus, StatementType +from src.models.osv import OsvEntry +from src.repositories import corrections as corrections_repo + +CompanyPair = frozenset[uuid.UUID] + + +class CorrectionInvalidationService: + """Переводит в DRAFT корректировки пар компаний, затронутых ручным назначением.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit) -> None: + self._session = session + self._audit = audit + + async def affected_pairs(self, entry_ids: Collection[uuid.UUID]) -> set[CompanyPair]: + """Получить пары владелец–контрагент непосредственно из исходных строк ОСВ.""" + rows = ( + await self._session.execute( + select(OsvEntry.owner_legal_entity_id, OsvEntry.counterparty_id).where(OsvEntry.id.in_(entry_ids)) + ) + ).all() + return { + frozenset((owner_id, counterparty_id)) + for owner_id, counterparty_id in rows + if counterparty_id is not None and owner_id != counterparty_id + } + + async def mark_draft(self, version_id: uuid.UUID, pairs: Collection[CompanyPair]) -> list[uuid.UUID]: + """Сбросить APPLIED у всех правил затронутых пар, сохранив введённые данные.""" + if not pairs: + return [] + rows = await corrections_repo.active_corrections(self._session, version_id, StatementType.balance) + changed = [ + row + for row in rows + if row.status == CorrectionStatus.APPLIED + and row.legal_entity_id is not None + and row.counterparty_id is not None + and frozenset((row.legal_entity_id, row.counterparty_id)) in pairs + ] + if not changed: + return [] + now = utc_now() + for row in changed: + row.status = CorrectionStatus.DRAFT + row.updated_by = self._audit.user_id + row.updated_at = now + await self._session.flush() + await self._audit.record( + action=AuditAction.CORRECTION_EDIT, + section=AuditSection.CONSOLIDATION, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + before_value={"statuses": {str(row.id): CorrectionStatus.APPLIED.value for row in changed}}, + after_value={"statuses": {str(row.id): CorrectionStatus.DRAFT.value for row in changed}}, + ) + return [row.id for row in changed] diff --git a/api/src/services/correction_source.py b/api/src/services/correction_source.py index 882fe7b..cb4f7f8 100644 --- a/api/src/services/correction_source.py +++ b/api/src/services/correction_source.py @@ -1,7 +1,6 @@ """Граница с разнесением ОСВ: адаптация готового результа ``VgoService``.""" import uuid -from collections.abc import Sequence from dataclasses import dataclass, field from decimal import Decimal from typing import Literal, Protocol, cast @@ -10,21 +9,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import ConflictError -from src.domain.consolidated_osv import ConsolidatedRow from src.domain.corrections import calculate_totals from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount from src.models.consolidation import ConsolidationVersion from src.models.enums import StatementType -from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoAssignmentRowOut +from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoUnallocatedPositionOut from src.schemas.corrections import ( AggregateSideOut, CorrectionAggregateOut, CorrectionEntryOut, CorrectionPairOut, CorrectionRuleOut, + UnallocatedEntryOut, ) -from src.services.consolidated_osv import ConsolidatedOsvService -from src.services.vgo import VgoService, _manual_key_amount +from src.services.vgo import VgoService ZERO = Decimal("0") COMPANY_NUMBERS = (1, 2) @@ -35,6 +33,7 @@ Side = Literal["D", "K"] class CorrectionSourceTable: rules: list[CorrectionRuleOut] = field(default_factory=list) aggregates: list[CorrectionAggregateOut] = field(default_factory=list) + unallocated: list[UnallocatedEntryOut] = field(default_factory=list) class CorrectionAggregateSource(Protocol): @@ -60,10 +59,9 @@ def _direction(rule: EliminationRule, company_no: int) -> Side: class VgoCorrectionSource: """Преобразует готовое автоматическое и ручное разнесение в таблицу корректировок.""" - def __init__(self, session: AsyncSession, vgo_service: VgoService, osv_service: ConsolidatedOsvService) -> None: + def __init__(self, session: AsyncSession, vgo_service: VgoService) -> None: self._session = session self._vgo_service = vgo_service - self._osv_service = osv_service async def _rules(self, statement_type: StatementType, version_no: int) -> dict[uuid.UUID, _RuleSpec]: accounts = list((await self._session.scalars(select(ChartOfAccount))).all()) @@ -96,13 +94,32 @@ class VgoCorrectionSource: return CorrectionSourceTable() specs = await self._rules(statement_type, version.elimination_rules_version) allocation = await self._vgo_service.assignment_candidates(version_id) - osv_result = await self._osv_service.build_with_members(version_id) - aggregates = [ - self._aggregate(group, specs[group.rule_id], osv_result.consolidated.rows) for group in allocation.groups - ] + aggregates = [self._aggregate(group, specs[group.rule_id]) for group in allocation.groups] used_rules = {group.rule_id for group in allocation.groups} + used_rules.update(rule_id for position in allocation.unallocated for rule_id in position.candidate_rule_ids) rules = [self._rule_out(spec) for rule_id, spec in specs.items() if rule_id in used_rules] - return CorrectionSourceTable(rules=rules, aggregates=aggregates) + unallocated = [self._unallocated(position, specs) for position in allocation.unallocated] + return CorrectionSourceTable(rules=rules, aggregates=aggregates, unallocated=unallocated) + + @staticmethod + def _unallocated(position: VgoUnallocatedPositionOut, specs: dict[uuid.UUID, _RuleSpec]) -> UnallocatedEntryOut: + lines = { + spec.rule.article_1_line if position.side == _direction(spec.rule, 1) else spec.rule.article_2_line + for rule_id in position.candidate_rule_ids + if (spec := specs.get(rule_id)) is not None + } + return UnallocatedEntryOut( + entry_ids=position.entry_ids, + owner=RefOut(id=position.legal_entity_id, name=position.legal_entity_name), + counterparty=RefOut(id=position.counterparty_id, name=position.counterparty_name), + account_code=position.account_code, + line_code=next(iter(lines)) if len(lines) == 1 else None, + side=position.side, + amount=position.amount, + candidate_rule_ids=position.candidate_rule_ids, + reason=position.reason, + excluded=position.excluded, + ) @staticmethod def _rule_out(spec: _RuleSpec) -> CorrectionRuleOut: @@ -121,57 +138,22 @@ class VgoCorrectionSource: ) @staticmethod - def _aggregate( - group: VgoAssignmentGroupOut, spec: _RuleSpec, rows: Sequence[ConsolidatedRow] - ) -> CorrectionAggregateOut: + def _aggregate(group: VgoAssignmentGroupOut, spec: _RuleSpec) -> CorrectionAggregateOut: rule = spec.rule side_1_code = _direction(rule, 1) - rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids} - - def amount(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side) -> Decimal: - if rule.measure == "turnover": - return (row.turnover_debit if side == "D" else row.turnover_credit) or ZERO - return (row.closing_debit if side == "D" else row.closing_credit) or ZERO - - def entry(row: ConsolidatedRow | VgoAssignmentRowOut, side: Side, value: Decimal) -> CorrectionEntryOut | None: - if row.counterparty_id is None: - return None - return CorrectionEntryOut( - entry_ids=list(row.entry_ids), - legal_entity_id=row.legal_entity_id, - counterparty_id=row.counterparty_id, - account_code=row.account_code or "", - side=side, - amount=value, + entries = [ + CorrectionEntryOut( + entry_ids=position.entry_ids, + legal_entity_id=position.legal_entity_id, + counterparty_id=position.counterparty_id, + account_code=position.account_code, + side=position.side, + amount=position.amount, + decision="ASSIGN" if position.action == "ASSIGN" else "AUTO", + candidate_rule_ids=position.candidate_rule_ids, ) - - entries_by_key: dict[tuple[tuple[uuid.UUID, ...], Side], CorrectionEntryOut] = {} - calculated = (*group.matched, *group.with_discrepancy) - for pair in calculated: - for entry_ids, side_code in ( - (pair.osv_entry_ids_a, side_1_code), - (pair.osv_entry_ids_b, _direction(rule, 2)), - ): - selected: dict[tuple[uuid.UUID, ...], ConsolidatedRow] = {} - for entry_id in entry_ids: - auto_row = rows_by_entry_id.get(entry_id) - if auto_row is not None: - selected.setdefault(tuple(auto_row.entry_ids), auto_row) - for auto_row in selected.values(): - item = entry(auto_row, side_code, amount(auto_row, side_code)) - if item is not None: - entries_by_key[(tuple(auto_row.entry_ids), side_code)] = item - - for manual_row in group.manual: - if manual_row.is_exclude: - continue - _, amount_1, amount_2 = _manual_key_amount(manual_row, rule) # type: ignore[arg-type] - side_code = side_1_code if amount_1 != ZERO or amount_2 == ZERO else _direction(rule, 2) - item = entry(manual_row, side_code, amount_1 if side_code == side_1_code else amount_2) - if item is not None: - entries_by_key[(tuple(manual_row.entry_ids), side_code)] = item - - entries = list(entries_by_key.values()) + for position in group.positions + ] def side(first: bool) -> AggregateSideOut: side_entries = [entry for entry in entries if (entry.side == side_1_code) == first] diff --git a/api/src/services/corrections.py b/api/src/services/corrections.py index 0a2361c..c8bd0b6 100644 --- a/api/src/services/corrections.py +++ b/api/src/services/corrections.py @@ -27,7 +27,7 @@ from src.schemas.corrections import ( CorrectionTotalsOut, ) from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version -from src.services.correction_source import CorrectionAggregateSource +from src.services.correction_source import CorrectionAggregateSource, CorrectionSourceTable from src.services.corrections_export import build_workbook from src.services.corrections_view import attach_corrections from src.services.corrections_write import correction_rows @@ -45,6 +45,15 @@ class CorrectionService: ) -> None: self._session, self._scope, self._principal = session, scope, principal self._source = source + self._source_cache_key: tuple[uuid.UUID, StatementType] | None = None + self._source_cache: CorrectionSourceTable | None = None + + async def _source_table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: + key = (version_id, statement_type) + if self._source_cache_key != key or self._source_cache is None: + self._source_cache = await self._source.table(version_id, statement_type) + self._source_cache_key = key + return self._source_cache async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionTableOut: found = await version_repo.get_version(self._session, self._scope, version_id) @@ -68,7 +77,7 @@ class CorrectionService: reason = "LOCKED_BY_OTHER" else: reason = "VERSION_NOT_EDITABLE" - source = await self._source.table(version_id, statement_type) + source = await self._source_table(version_id, statement_type) rules = sorted(source.rules, key=lambda item: (item.rule_no, item.id)) aggregates = sorted( source.aggregates, @@ -107,6 +116,7 @@ class CorrectionService: read_only_reason=reason, rules=rules, aggregates=aggregates, + unallocated=source.unallocated, totals=CorrectionTotalsOut( amount_1=sum((a.side_1.amount for a in aggregates), ZERO), amount_2=sum((a.side_2.amount for a in aggregates), ZERO), diff --git a/api/src/services/vgo.py b/api/src/services/vgo.py index 2c3d1c2..eaa5c2a 100644 --- a/api/src/services/vgo.py +++ b/api/src/services/vgo.py @@ -2,9 +2,10 @@ import uuid from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, Sequence, Set from dataclasses import dataclass, field from decimal import Decimal +from typing import Literal, cast from sqlalchemy.ext.asyncio import AsyncSession @@ -35,7 +36,9 @@ from src.schemas.consolidations import ( VgoAssignmentExcludeIn, VgoAssignmentGroupOut, VgoAssignmentOut, + VgoAssignmentPositionOut, VgoAssignmentRowOut, + VgoUnallocatedPositionOut, ) from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService from src.services.consolidation_guard import lock_editable_version @@ -210,15 +213,40 @@ def _select_diff_aggregates( return diff_aggregates -def _build_groups( +def _position( + row: ConsolidatedRow | VgoAssignmentRowOut, + side: Literal["D", "K"], + amount: Decimal, + *, + action: Literal["ASSIGN"] | None = None, + candidate_rule_ids: Sequence[uuid.UUID] = (), +) -> VgoAssignmentPositionOut | None: + if row.counterparty_id is None: + return None + return VgoAssignmentPositionOut( + entry_ids=list(row.entry_ids), + legal_entity_id=row.legal_entity_id, + counterparty_id=row.counterparty_id, + account_code=row.account_code or "", + side=side, + amount=amount, + action=action, + candidate_rule_ids=list(candidate_rule_ids), + ) + + +def _build_groups( # noqa: PLR0913, PLR0917 - результат поиска и справочники передаются раздельно search: _AggregateSearchResult, diff_aggregates: dict[GroupKey, list[EliminationMatchedPairOut]], rules: Sequence[EliminationRule], manual: dict[GroupKey, list[ManualItem]], names: Mapping[uuid.UUID, str], + rows: Sequence[ConsolidatedRow], + owners: Mapping[uuid.UUID, Set[uuid.UUID]], ) -> list[VgoAssignmentGroupOut]: """Сгруппировать принятые агрегаты и посчитать суммы по правилу и компаниям.""" rule_by_id = {rule.id: rule for rule in rules} + rows_by_entry_id = {entry_id: row for row in rows for entry_id in row.entry_ids} keys = list(dict.fromkeys([*search.matched_aggregates, *diff_aggregates, *manual])) groups = [] for rule_id, company_a_id, company_b_id in keys: @@ -227,6 +255,64 @@ def _build_groups( with_discrepancy = diff_aggregates.get(key, []) aggregates = [*matched, *with_discrepancy] manual_rows = manual.get(key, []) + rule = rule_by_id[rule_id] + if rule.side_1 not in ("D", "K"): + raise DomainValidationError("INVALID_VGO_RULE_SIDE", "У правила задана неизвестная сторона") + side_a = cast(Literal["D", "K"], rule.side_1) + side_b: Literal["D", "K"] = "K" if side_a == "D" else "D" + positions: dict[tuple[tuple[uuid.UUID, ...], str], VgoAssignmentPositionOut] = {} + + for aggregate in aggregates: + for entry_ids, side in ((aggregate.osv_entry_ids_a, side_a), (aggregate.osv_entry_ids_b, side_b)): + selected = { + tuple(rows_by_entry_id[entry_id].entry_ids): rows_by_entry_id[entry_id] for entry_id in entry_ids + } + for source_position in selected.values(): + if rule.measure == "turnover": + amount = ( + source_position.turnover_debit if side == "D" else source_position.turnover_credit + ) or ZERO + else: + amount = ( + source_position.closing_debit if side == "D" else source_position.closing_credit + ) or ZERO + candidate_ids = ( + owners.get(source_position.account_id, frozenset()) + if source_position.account_id is not None + else frozenset() + ) + position = _position( + source_position, + side, + amount, + candidate_rule_ids=sorted( + candidate_ids - {rule_id}, + key=lambda item: (rule_by_id[item].rule_no, str(item)), + ), + ) + if position is not None: + positions[(tuple(position.entry_ids), position.side)] = position + + for manual_row, amount_a, amount_b in manual_rows: + side = side_a if amount_a != ZERO or amount_b == ZERO else side_b + source_row = rows_by_entry_id.get(manual_row.entry_ids[0]) if manual_row.entry_ids else None + candidate_ids = ( + owners.get(source_row.account_id, frozenset()) + if source_row is not None and source_row.account_id is not None + else frozenset() + ) + position = _position( + manual_row, + side, + amount_a if side == side_a else amount_b, + action=None if manual_row.is_auto_assigned else "ASSIGN", + candidate_rule_ids=sorted( + candidate_ids - {rule_id}, + key=lambda item: (rule_by_id[item].rule_no, str(item)), + ), + ) + if position is not None: + positions[(tuple(position.entry_ids), position.side)] = position amount_a = sum((aggregate.amount_a for aggregate in aggregates), ZERO) amount_b = sum((aggregate.amount_b for aggregate in aggregates), ZERO) amount_a += sum((item_amount_a for _, item_amount_a, _ in manual_rows), ZERO) @@ -250,11 +336,49 @@ def _build_groups( matched=matched, with_discrepancy=with_discrepancy, manual=[row for row, _, _ in manual_rows], + positions=list(positions.values()), ) ) return groups +def _unallocated_positions( + rows: Sequence[VgoAssignmentRowOut], + source_rows: Sequence[ConsolidatedRow], + owners: Mapping[uuid.UUID, Set[uuid.UUID]], + rule_order: Mapping[uuid.UUID, int], +) -> list[VgoUnallocatedPositionOut]: + """Дополнить результат разнесения представлением нераспределённых позиций.""" + source_by_key = {row.entry_ids: row for row in source_rows} + result = [] + for row in rows: + source = source_by_key.get(tuple(row.entry_ids)) + if source is None or row.counterparty_id is None: + continue + debit, credit = source.closing_debit or ZERO, source.closing_credit or ZERO + side: Literal["D", "K"] = "D" if debit != ZERO or credit == ZERO else "K" + candidate_ids = owners.get(source.account_id, frozenset()) if source.account_id is not None else frozenset() + reason: Literal["MULTIPLE_RULES", "UNASSIGNED"] = ( + "UNASSIGNED" if row.action == "UNASSIGNED" or row.unassigned_reason is not None else "MULTIPLE_RULES" + ) + result.append( + VgoUnallocatedPositionOut( + entry_ids=row.entry_ids, + legal_entity_id=row.legal_entity_id, + legal_entity_name=row.legal_entity_name, + counterparty_id=row.counterparty_id, + counterparty_name=row.counterparty_name or str(row.counterparty_id), + account_code=row.account_code or "", + side=side, + amount=debit if side == "D" else credit, + candidate_rule_ids=sorted(candidate_ids, key=lambda item: (rule_order.get(item, 0), str(item))), + reason=reason, + excluded=row.is_exclude, + ) + ) + return result + + class VgoService: """Операции ВГО над сводной ОСВ версии консолидации.""" @@ -349,16 +473,28 @@ class VgoService: if name is not None: names.setdefault(row.counterparty_id, name) - groups = _build_groups(search, diff_aggregates, rules, manual.groups, names) + groups = _build_groups( + search, diff_aggregates, rules, manual.groups, names, osv_result.consolidated.rows, owners + ) unassigned = _unassigned_rows(decision_rows, manual, distribution) - return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned) + unallocated = _unallocated_positions( + unassigned, + osv_result.consolidated.rows, + owners, + {rule.id: rule.rule_no for rule in rules}, + ) + return VgoAssignmentCandidatesOut(groups=groups, unassigned=unassigned, unallocated=unallocated) class VgoAssignmentCrudService: """Ручная правка назначений исходных строк ОСВ одной версии.""" def __init__( - self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, osv_service: ConsolidatedOsvService + self, + session: AsyncSession, + audit: RequestAudit, + scope: VisibilityScope, + osv_service: ConsolidatedOsvService, ) -> None: self.session = session self.audit = audit diff --git a/api/tests/test_api_corrections.py b/api/tests/test_api_corrections.py index 75e73b5..4133ded 100644 --- a/api/tests/test_api_corrections.py +++ b/api/tests/test_api_corrections.py @@ -34,13 +34,16 @@ TRADE_RULE_NO = 6 class MutableCorrectionSource: def __init__(self) -> None: self.tables: dict[StatementType, CorrectionSourceTable] = {} + self.calls = 0 async def table(self, version_id, statement_type): del version_id + self.calls += 1 table = self.tables.get(statement_type, CorrectionSourceTable()) return CorrectionSourceTable( rules=[rule.model_copy(deep=True) for rule in table.rules], aggregates=[aggregate.model_copy(deep=True) for aggregate in table.aggregates], + unallocated=[entry.model_copy(deep=True) for entry in table.unallocated], ) @@ -74,6 +77,8 @@ async def configure_trade_source( account_code=account, side=side, amount=amount, + decision="AUTO", + candidate_rule_ids=[], ) aggregate = CorrectionAggregateOut.model_validate( diff --git a/api/tests/test_api_corrections_mutations.py b/api/tests/test_api_corrections_mutations.py index 00256ed..7187e51 100644 --- a/api/tests/test_api_corrections_mutations.py +++ b/api/tests/test_api_corrections_mutations.py @@ -108,6 +108,7 @@ async def test_create_replace_draft_and_conflict( world = await draft_world(seeded, fixtures_dir, correction_source) client = await auth_client(KA_RF) path, initial = await draft_url(client, world.version.id) + correction_source.calls = 0 saved = await client.put(path, json=draft_body()) assert saved.status_code == OK, saved.text aggregate = saved.json() @@ -119,6 +120,7 @@ async def test_create_replace_draft_and_conflict( assert correction["comment"] == "Разнесение задолженности" assert correction["balance"]["difference"] == "10.00" assert correction["balance"]["remainder_1"] == "106311016.46" + assert correction_source.calls == 1 rows = list((await seeded.scalars(select(Correction))).all()) roots = {row.id for row in rows if row.parent_correction_id is None} assert len(rows) == INITIAL_ARTICLE_COUNT diff --git a/api/tests/test_api_vgo_assignments.py b/api/tests/test_api_vgo_assignments.py index 7e36a1c..d1b05d5 100644 --- a/api/tests/test_api_vgo_assignments.py +++ b/api/tests/test_api_vgo_assignments.py @@ -10,6 +10,7 @@ from pydantic import ValidationError from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncSession +from src.api.endpoints.consolidations import vgo_assignment as endpoint from src.api.endpoints.consolidations.vgo_assignment import router from src.common.clock import utc_now from src.common.exceptions import DomainValidationError @@ -51,6 +52,25 @@ def test_inputs_require_unique_ids() -> None: VgoAssignmentExcludeIn(statement_type="balance", entry_ids=[entry_id, entry_id], is_exclude=True) +async def test_endpoint_marks_affected_pair_draft_after_assignment() -> None: + version_id, entry_id, rule_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + pair = frozenset((uuid.uuid4(), uuid.uuid4())) + result = [SimpleNamespace(id=uuid.uuid4())] + assignment_service = SimpleNamespace(attach_rule=AsyncMock(return_value=result)) + corrections = SimpleNamespace( + affected_pairs=AsyncMock(return_value={pair}), + mark_draft=AsyncMock(), + ) + data = VgoAssignmentAttachRuleIn(entry_ids=[entry_id], rule_id=rule_id) + + response = await endpoint.attach_rule(version_id, data, assignment_service, corrections) + + assert response == result + corrections.affected_pairs.assert_awaited_once_with([entry_id]) + assignment_service.attach_rule.assert_awaited_once_with(version_id, data) + corrections.mark_draft.assert_awaited_once_with(version_id, {pair}) + + async def test_write_requires_complete_consolidated_row() -> None: entry_a, entry_b = uuid.uuid4(), uuid.uuid4() row = SimpleNamespace(entry_ids=(entry_a, entry_b)) @@ -199,6 +219,8 @@ async def test_exclusion_creates_missing_assignment(monkeypatch: pytest.MonkeyPa session.flush.assert_awaited_once() +@pytest.mark.integration +@pytest.mark.fixtures async def test_vgo_assignment_integration(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: constraints = ( await seeded.execute( diff --git a/api/tests/test_correction_invalidation.py b/api/tests/test_correction_invalidation.py new file mode 100644 index 0000000..fd82b12 --- /dev/null +++ b/api/tests/test_correction_invalidation.py @@ -0,0 +1,65 @@ +"""Инвалидация корректировок затронутой пары без полного пересчёта распределения VGO.""" + +import uuid +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.models.enums import CorrectionStatus +from src.models.kfo import Correction +from src.services import correction_invalidation + + +def _correction(company_a: uuid.UUID, company_b: uuid.UUID, status: CorrectionStatus) -> Correction: + return Correction( + id=uuid.uuid4(), + legal_entity_id=company_a, + counterparty_id=company_b, + debit_line_code="1230", + debit_adjustment=Decimal("10"), + comment="Причина расхождения", + status=status, + ) + + +@pytest.mark.asyncio +async def test_all_applied_corrections_of_affected_pair_become_draft(monkeypatch: pytest.MonkeyPatch) -> None: + version_id, company_a, company_b, company_c = (uuid.uuid4() for _ in range(4)) + first_rule = _correction(company_a, company_b, CorrectionStatus.APPLIED) + second_rule = _correction(company_b, company_a, CorrectionStatus.APPLIED) + already_draft = _correction(company_a, company_b, CorrectionStatus.DRAFT) + unrelated = _correction(company_a, company_c, CorrectionStatus.APPLIED) + monkeypatch.setattr( + correction_invalidation.corrections_repo, + "active_corrections", + AsyncMock(return_value=[first_rule, second_rule, already_draft, unrelated]), + ) + session = SimpleNamespace(flush=AsyncMock()) + audit = SimpleNamespace(user_id=uuid.uuid4(), record=AsyncMock()) + service = correction_invalidation.CorrectionInvalidationService(session, audit) + + changed = await service.mark_draft(version_id, {frozenset((company_a, company_b))}) + + assert set(changed) == {first_rule.id, second_rule.id} + assert first_rule.status == second_rule.status == CorrectionStatus.DRAFT + assert already_draft.status == CorrectionStatus.DRAFT + assert unrelated.status == CorrectionStatus.APPLIED + assert first_rule.debit_adjustment == Decimal("10") + assert first_rule.comment == "Причина расхождения" + session.flush.assert_awaited_once() + audit.record.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_affected_pairs_are_read_directly_from_osv_entries() -> None: + owner_id, counterparty_id, entry_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + query_result = SimpleNamespace(all=lambda: [(owner_id, counterparty_id), (owner_id, None)]) + session = SimpleNamespace(execute=AsyncMock(return_value=query_result)) + service = correction_invalidation.CorrectionInvalidationService(session, SimpleNamespace(user_id=uuid.uuid4())) + + pairs = await service.affected_pairs([entry_id]) + + assert pairs == {frozenset((owner_id, counterparty_id))} + session.execute.assert_awaited_once() diff --git a/api/tests/test_correction_source.py b/api/tests/test_correction_source.py index 50c7e96..2a0df61 100644 --- a/api/tests/test_correction_source.py +++ b/api/tests/test_correction_source.py @@ -4,52 +4,15 @@ import uuid from decimal import Decimal from types import SimpleNamespace -from src.domain.consolidated_osv import CounterpartyType from src.schemas.consolidations import ( EliminationMatchedPairOut, VgoAssignmentGroupOut, - VgoAssignmentRowOut, + VgoAssignmentPositionOut, + VgoUnallocatedPositionOut, ) from src.services.correction_source import VgoCorrectionSource, _RuleSpec -def _row( # noqa: PLR0913 - компактная фабрика строки ОСВ для одного теста - entry_id: uuid.UUID, - owner_id: uuid.UUID, - counterparty_id: uuid.UUID, - account: str, - *, - debit: Decimal = Decimal("0"), - credit: Decimal = Decimal("0"), -): - return VgoAssignmentRowOut( - entry_ids=[entry_id], - legal_entity_id=owner_id, - legal_entity_name="Владелец", - legal_entity_inn="1234567890", - account_code=account, - account_root=account.split(".", 1)[0], - period="2026 Q1", - is_cumulative=True, - row_number=1, - counterparty_name="Контрагент", - counterparty_inn="0987654321", - counterparty_id=counterparty_id, - counterparty_entity_name="Контрагент", - counterparty_entity_inn="0987654321", - counterparty_type=CounterpartyType.GSK, - match_kind=None, - match_stored=True, - opening_debit=Decimal("0"), - opening_credit=Decimal("0"), - turnover_debit=Decimal("0"), - turnover_credit=Decimal("0"), - closing_debit=debit, - closing_credit=credit, - warnings=[], - ) - - def test_vgo_positions_form_oriented_company_aggregate() -> None: first_id, second_id, rule_id = (uuid.uuid4() for _ in range(3)) rule = SimpleNamespace( @@ -88,13 +51,29 @@ def test_vgo_positions_form_oriented_company_aggregate() -> None: osv_entry_ids_b=[uuid.UUID(int=2)], ) ], + positions=[ + VgoAssignmentPositionOut( + entry_ids=[uuid.UUID(int=1)], + legal_entity_id=first_id, + counterparty_id=second_id, + account_code="62.01", + side="D", + amount=Decimal("100"), + candidate_rule_ids=[uuid.UUID(int=7)], + ), + VgoAssignmentPositionOut( + entry_ids=[uuid.UUID(int=2)], + legal_entity_id=second_id, + counterparty_id=first_id, + account_code="60.01", + side="K", + amount=Decimal("95"), + action="ASSIGN", + ), + ], ) - rows = [ - _row(uuid.UUID(int=1), first_id, second_id, "62.01", debit=Decimal("100")), - _row(uuid.UUID(int=2), second_id, first_id, "60.01", credit=Decimal("95")), - ] - aggregate = VgoCorrectionSource._aggregate(group, _RuleSpec(rule), rows) # type: ignore[arg-type] + aggregate = VgoCorrectionSource._aggregate(group, _RuleSpec(rule)) # type: ignore[arg-type] assert (aggregate.company_1.id, aggregate.company_2.id) == (first_id, second_id) assert aggregate.side_1.amount == Decimal("100.00") @@ -102,5 +81,48 @@ def test_vgo_positions_form_oriented_company_aggregate() -> None: assert aggregate.deviation_percent == Decimal("5") / Decimal("95") * 100 assert aggregate.exceeds_threshold assert aggregate.side_1.entries[0].entry_ids == [uuid.UUID(int=1)] + assert aggregate.side_1.entries[0].decision == "AUTO" + assert aggregate.side_1.entries[0].candidate_rule_ids == [uuid.UUID(int=7)] + assert aggregate.side_2.entries[0].decision == "ASSIGN" assert aggregate.pairs[0].side_1.entries[0].entry_ids == [uuid.UUID(int=1)] assert aggregate.pairs[0].side_2.entries[0].entry_ids == [uuid.UUID(int=2)] + + +def test_unallocated_position_forms_correction_contract() -> None: + owner_id, counterparty_id, first_rule_id, second_rule_id = (uuid.uuid4() for _ in range(4)) + first = SimpleNamespace( + id=first_rule_id, + side_1="D", + article_1_line="1230", + article_2_line="1520", + ) + second = SimpleNamespace( + id=second_rule_id, + side_1="D", + article_1_line="1230", + article_2_line="1510", + ) + position = VgoUnallocatedPositionOut( + entry_ids=[uuid.UUID(int=9)], + legal_entity_id=owner_id, + legal_entity_name="Компания А", + counterparty_id=counterparty_id, + counterparty_name="Компания Б", + account_code="76.05", + side="D", + amount=Decimal("142878"), + candidate_rule_ids=[first_rule_id, second_rule_id], + reason="MULTIPLE_RULES", + excluded=False, + ) + + result = VgoCorrectionSource._unallocated( + position, + {first_rule_id: _RuleSpec(first), second_rule_id: _RuleSpec(second)}, # type: ignore[arg-type] + ) + + assert result.owner.id == owner_id + assert result.counterparty.id == counterparty_id + assert result.line_code == "1230" + assert result.candidate_rule_ids == [first_rule_id, second_rule_id] + assert result.reason == "MULTIPLE_RULES" diff --git a/api/tests/test_corrections_export.py b/api/tests/test_corrections_export.py index 9ea62d5..aedf9e6 100644 --- a/api/tests/test_corrections_export.py +++ b/api/tests/test_corrections_export.py @@ -32,6 +32,8 @@ def table(fixtures_dir: Path) -> CorrectionTableOut: account_code=source["account_code_raw"], side=side, amount=Decimal(source["closing_debit" if side == "D" else "closing_credit"]), + decision="AUTO", + candidate_rule_ids=[], ) debit_entry = entry(debit, side="D", entry_id=2) @@ -64,6 +66,7 @@ def table(fixtures_dir: Path) -> CorrectionTableOut: correction=None, ) ], + unallocated=[], totals=dict(amount_1=amount, amount_2=amount, deviation_amount=0, correction_debit=0, correction_credit=0), ) ) diff --git a/api/tests/test_corrections_review_fixes.py b/api/tests/test_corrections_review_fixes.py new file mode 100644 index 0000000..dfe4e81 --- /dev/null +++ b/api/tests/test_corrections_review_fixes.py @@ -0,0 +1,30 @@ +import uuid + +from src.models.enums import StatementType +from src.services.correction_source import CorrectionSourceTable +from src.services.corrections import CorrectionService + +DISTINCT_SOURCE_KEYS = 2 + + +async def test_correction_source_is_built_once_per_service_request() -> None: + class CountingSource: + def __init__(self) -> None: + self.calls = 0 + + async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: + del version_id, statement_type + self.calls += 1 + return CorrectionSourceTable() + + source = CountingSource() + service = CorrectionService(None, None, None, source) # type: ignore[arg-type] + version_id = uuid.uuid4() + + first = await service._source_table(version_id, StatementType.balance) # noqa: SLF001 + second = await service._source_table(version_id, StatementType.balance) # noqa: SLF001 + third = await service._source_table(version_id, StatementType.income) # noqa: SLF001 + + assert first is second + assert third is not second + assert source.calls == DISTINCT_SOURCE_KEYS diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 337899e..202dda4 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -119,10 +119,7 @@ async def schema_snapshot(conn: AsyncConnection) -> dict[str, Any]: ).all() return { "columns": [tuple(row) for row in columns], - "constraints": { - row[0].replace("ck_vgo_assignment_ck_vgo_assignment_", "ck_vgo_assignment_"): (row[1], row[2]) - for row in constraints - }, + "constraints": {row[0]: (row[1], row[2]) for row in constraints}, "indexes": {row[0]: row[1] for row in indexes}, "enums": {row[0]: list(row[1]) for row in enums}, } diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index b7cf814..bf47e58 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -86,7 +86,9 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"}, "/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"}, - "/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"post"}, + "/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"get"}, + "/api/v1/consolidations/versions/{version_id}/vgo-assignments/rule": {"put", "delete"}, + "/api/v1/consolidations/versions/{version_id}/vgo-assignments/exclude": {"put"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"}, diff --git a/api/tests/test_vgo.py b/api/tests/test_vgo.py index 4fa284e..8b5b467 100644 --- a/api/tests/test_vgo.py +++ b/api/tests/test_vgo.py @@ -132,9 +132,8 @@ async def test_unpaired_row_with_unique_rule_account_is_auto_assigned(monkeypatc _resolve_preset_selection=AsyncMock(side_effect=resolve), build_pairs=AsyncMock(return_value=SimpleNamespace(matched=[], with_discrepancy=[])), ) - service = vgo.VgoService( - AsyncMock(), SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)), matrix - ) + osv_service = SimpleNamespace(build_with_members=AsyncMock(return_value=osv_result)) + service = vgo.VgoService(AsyncMock(), osv_service, matrix) response = await service.assignment_candidates(version_id) @@ -144,7 +143,16 @@ async def test_unpaired_row_with_unique_rule_account_is_auto_assigned(monkeypatc assert response.groups[0].manual[0].entry_ids == [unique_row.entry_ids[0]] assert response.groups[0].manual[0].is_auto_assigned is True assert response.groups[0].manual[0].is_exclude is False + assert len(response.groups[0].positions) == 1 + assert response.groups[0].positions[0].side == "D" + assert response.groups[0].positions[0].amount == Decimal("7.00") + assert response.groups[0].positions[0].action is None assert [row.entry_ids for row in response.unassigned] == [[shared_row.entry_ids[0]]] + assert len(response.unallocated) == 1 + assert response.unallocated[0].side == "D" + assert response.unallocated[0].amount == Decimal("3.00") + assert response.unallocated[0].candidate_rule_ids == [rule_1, rule_2] + assert response.unallocated[0].reason == "MULTIPLE_RULES" assert "match_stored" not in response.unassigned[0].model_dump() @@ -163,6 +171,7 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest rule_no=1, statement_type=StatementType.balance, measure="closing", + side_1="D", article_1_line="Статья A1", article_2_line="Статья B1", ), @@ -171,6 +180,7 @@ async def test_pairs_are_grouped_and_entry_ids_are_used_once(monkeypatch: pytest rule_no=2, statement_type=StatementType.balance, measure="closing", + side_1="D", article_1_line="Статья A2", article_2_line="Статья B2", ), @@ -303,9 +313,12 @@ async def test_manual_assignments_override_auto_and_show_exclusion_flag(monkeypa ([entry_ids[1]], False), ] assert "manual" in response.model_dump()["groups"][0] + assert [position.action for position in response.groups[0].positions] == ["ASSIGN", "ASSIGN"] assert [(row.entry_ids, row.action, row.is_exclude) for row in response.unassigned] == [ ([entry_ids[2]], "UNASSIGNED", True), ] + assert response.unallocated[0].reason == "UNASSIGNED" + assert response.unallocated[0].excluded is True async def test_partial_manual_assignment_stays_unassigned_and_is_not_searched(monkeypatch: pytest.MonkeyPatch) -> None: @@ -335,6 +348,7 @@ async def test_partial_manual_assignment_stays_unassigned_and_is_not_searched(mo assert [item.entry_ids for item in response.unassigned] == [[entry_a, entry_b]] assert response.unassigned[0].unassigned_reason == "PARTIAL_MANUAL_ASSIGNMENT" assert response.unassigned[0].action is None + assert response.unallocated[0].reason == "UNASSIGNED" assert all(call.args[4] == [free_row] for call in matrix.build_pairs.await_args_list) diff --git a/openspec/changes/corrections-api-stage-one/design.md b/openspec/changes/corrections-api-stage-one/design.md index 4c0c5c5..8323455 100644 --- a/openspec/changes/corrections-api-stage-one/design.md +++ b/openspec/changes/corrections-api-stage-one/design.md @@ -39,7 +39,8 @@ ## Risks / Trade-offs - [Результат AURORA-1751 изменит внутреннее представление] → зависимость изолирована интерфейсом `CorrectionAggregateSource` и адаптером `VgoCorrectionSource`. -- [Состав агрегата меняется в соседней задаче] → lifecycle `DRAFT/REVOKED` по изменению состава согласуется и реализуется там либо отдельным change; этот этап не отслеживает переносы строк. +- [Точный состав агрегата требует дорогого полного распределения] → после ASSIGN/UNASSIGN все `APPLIED`-корректировки + пары компаний консервативно переводятся в `DRAFT` по данным `osv_entry`; `REVOKED` без точного состава не используется. - [Ровно 5 % ранее трактовалось как превышение] → зафиксировать `≤ 5 %` отдельным граничным тестом. ## Migration Plan diff --git a/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md index 4b5c89c..6fbc6c7 100644 --- a/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md +++ b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md @@ -86,3 +86,13 @@ #### Scenario: Контракт этапа - **WHEN** публикуется OpenAPI этапа корректировок - **THEN** в нём отсутствуют операции импорта и назначения строк правилам + +### Requirement: Инвалидация корректировки при ручном назначении +После успешного ASSIGN/UNASSIGN система SHALL определять затронутые пары компаний непосредственно по `entry_ids` в +`osv_entry` и переводить все их `APPLIED`-корректировки в `DRAFT`. Суммы, статьи и комментарий SHALL сохраняться. +Операция MUST NOT выполнять полный расчёт `assignment_candidates`; статус `REVOKED` на этом этапе не устанавливается. + +#### Scenario: Изменено назначение строки пары компаний +- **WHEN** аналитик назначает строку правилу или снимает назначение +- **THEN** все `APPLIED`-корректировки этой пары компаний получают `DRAFT` +- **THEN** корректировки других пар и уже находящиеся в `DRAFT` не изменяются diff --git a/openspec/changes/corrections-api-stage-one/tasks.md b/openspec/changes/corrections-api-stage-one/tasks.md index 7f5e7ec..8e0f990 100644 --- a/openspec/changes/corrections-api-stage-one/tasks.md +++ b/openspec/changes/corrections-api-stage-one/tasks.md @@ -4,7 +4,7 @@ - [x] 1.1 [arm-core] Заменить локальное разнесение ОСВ интерфейсом `CorrectionAggregateSource`; реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` AURORA-1751 и не читает схему назначений напрямую. Проверка: GET не создаёт назначения, пары или корректировки. - [x] 1.2 [arm-core] Обновить `api/src/domain/corrections.py` и граничные тесты: при отклонении `≤ 5 %` `eliminated_amount = min`, разница остаётся в большей статье; `> 5 %` требует корректировки; нулевая меньшая сторона даёт `percent = null`. -- [x] 1.3 [arm-core] Удалить из schemas, README и тестов `unallocated`, manual decision и прочие поля, принадлежащие разнесению, если их не предоставляет утверждённый интерфейс источника. Проверка: поиск по `ASSIGN/UNASSIGN/EXCLUDE/INCLUDE` не находит контракт корректировок. +- [x] 1.3 [arm-core] Передать из результата AURORA-1751 в контракт корректировок нераспределённые позиции: `unallocated`, `decision`, `candidate_rule_ids` и `reason`; сервис корректировок не вычисляет эти значения самостоятельно. ## 2. Сохранение корректировки @@ -25,6 +25,7 @@ - [x] 5.1 [arm-core] Интегрировать расчёт AURORA-1751 через публичный результат `assignment_candidates`; передать в адаптер позиции, `matched` и `with_discrepancy`, не добавляя модель данных для вычисленных пар. - [x] 5.2 [arm-core] Добавить тесты источника и экспорта: состав обеих сторон и суммы пары совпадают с результатом 1751, а XLSX выводит сопоставленную пару одной строкой без повторения входящих позиций. +- [x] 5.3 [arm-core] После ASSIGN/UNASSIGN определить затронутые пары по `entry_ids` в `osv_entry` и перевести их `APPLIED`-корректировки в `DRAFT` без вызова `assignment_candidates`, сохранив суммы, статьи и комментарий. ### Результаты проверки 30.10.2026 -- 2.47.2 From 948724cf1e72c82b855ddd7d819498e40c0c529f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 20:17:41 +0400 Subject: [PATCH 09/10] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20#208=20(?= =?UTF-8?q?I-5):=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=20PostgreSQL=20=D0=B2=20.env.test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Был 5431 вместо 5432 из docs/dev/docker-compose.yml, как и утверждает комментарий в шапке файла — интеграционные тесты не подключались к стенду, поднятому make up. --- api/.env.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/.env.test b/api/.env.test index d441ad1..522fdf0 100644 --- a/api/.env.test +++ b/api/.env.test @@ -11,7 +11,7 @@ DB_SCHEMA=dkb SERVER__HOST=127.0.0.1 SERVER__PORT=8000 -OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5431/armka +OPENBAO__DATABASE__DATABASE_URL=postgresql+asyncpg://armka:armka@localhost:5432/armka OPENBAO__MINIO__MINIO_URL=localhost:9000 OPENBAO__MINIO__MINIO_ADMIN_LOGIN=minioadmin -- 2.47.2 From d542a228e0c3ddd9ec0f35dc1342f9dfd465da49 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 2 Nov 2026 20:17:48 +0400 Subject: [PATCH 10/10] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20#208=20(?= =?UTF-8?q?I-7):=20=D0=BE=D1=81=D0=B8=D1=80=D0=BE=D1=82=D0=B5=D0=B2=D1=88?= =?UTF-8?q?=D0=B0=D1=8F=20APPLIED-=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=B1=D1=83=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach_corrections молча пропускала (continue) корректировку, чей агрегат (rule_id, пара ЮЛ) больше не существует после пересчёта разнесения ВГО — вместо этого, как и в остальных ветках функции, поднимаем ConflictError("CORRECTION_REVIEW_REQUIRED"). --- api/src/services/corrections_view.py | 2 +- api/tests/test_corrections_view.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 api/tests/test_corrections_view.py diff --git a/api/src/services/corrections_view.py b/api/src/services/corrections_view.py index d3eb719..c301d26 100644 --- a/api/src/services/corrections_view.py +++ b/api/src/services/corrections_view.py @@ -31,7 +31,7 @@ def attach_corrections( for key, corrections in grouped.items(): aggregate = by_key.get(key) if aggregate is None: - continue + raise ConflictError("CORRECTION_REVIEW_REQUIRED", "Корректировка ссылается на агрегат, которого больше нет") ordered = sorted(corrections, key=lambda c: (c.parent_correction_id is not None, c.id)) roots = [c for c in ordered if c.parent_correction_id is None] root_ids = {c.id for c in roots} diff --git a/api/tests/test_corrections_view.py b/api/tests/test_corrections_view.py new file mode 100644 index 0000000..5e30fc9 --- /dev/null +++ b/api/tests/test_corrections_view.py @@ -0,0 +1,56 @@ +import uuid +from decimal import Decimal + +import pytest + +from src.common.exceptions import ConflictError +from src.models.kfo import Correction +from src.schemas.consolidations import RefOut +from src.schemas.corrections import AggregateSideOut, CorrectionAggregateOut +from src.services.corrections_view import attach_corrections + +ZERO = Decimal("0") + + +def _side() -> AggregateSideOut: + return AggregateSideOut(article_line="1230", accounts=[], amount=ZERO, entries=[]) + + +def _aggregate(rule_id: uuid.UUID, company_1: uuid.UUID, company_2: uuid.UUID) -> CorrectionAggregateOut: + return CorrectionAggregateOut( + rule_id=rule_id, + rule_no=1, + company_1=RefOut(id=company_1, name="А"), + company_2=RefOut(id=company_2, name="Б"), + side_1=_side(), + side_2=_side(), + deviation_amount=ZERO, + deviation_percent=None, + exceeds_threshold=False, + status="AUTO", + eliminated_amount=ZERO, + correction=None, + ) + + +def _correction(rule_id: uuid.UUID, legal_entity_id: uuid.UUID, counterparty_id: uuid.UUID) -> Correction: + return Correction( + id=uuid.uuid4(), + rule_id=rule_id, + legal_entity_id=legal_entity_id, + counterparty_id=counterparty_id, + parent_correction_id=None, + comment="комментарий", + version_no=1, + ) + + +async def test_attach_corrections_rejects_correction_for_aggregate_that_no_longer_exists() -> None: + rule_id, company_1, company_2 = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + aggregates = [_aggregate(uuid.uuid4(), company_1, company_2)] + corrections = [_correction(rule_id, company_1, company_2)] + + with pytest.raises(ConflictError) as excinfo: + attach_corrections(aggregates, corrections, {}) + + assert excinfo.value.code == "CORRECTION_REVIEW_REQUIRED" -- 2.47.2