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/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/README.md b/README.md index 20f9316..e25604d 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 .` | @@ -1192,9 +1192,42 @@ queue group `arm-core`, подписчики группы получают со чужому экземпляру (он отбросит их как `unknown_job`, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только `tabular-parser` (`--no-deps`), как в команде выше. +### Основа таблицы корректировок (AURORA-1540, в работе) + +Текущая задача не реализует ручное назначение, перенос или исключение строк ОСВ через API (запись и перенос в +`vgo_assignment` — AURORA-1751). + +Чистая функция `domain/corrections.py` реализует контроль готового агрегата: при отклонении не более 5 % +справочная сумма элиминации равна меньшей стороне. Разнесение строк ОСВ по правилам и хранение ручных решений +(таблица `vgo_assignment`) — ответственность AURORA-1751 (`services/vgo.py`). API корректировок получает уже +вычисленные агрегаты и пары через интерфейс `CorrectionAggregateSource` (реализация `VgoCorrectionSource` вызывает +`VgoService.assignment_candidates`) и не читает схему `vgo_assignment` напрямую. Этот 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 +1334,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_dbml_v5_24.py b/api/alembic/versions/0011_dbml_v5_24.py new file mode 100644 index 0000000..6d87790 --- /dev/null +++ b/api/alembic/versions/0011_dbml_v5_24.py @@ -0,0 +1,66 @@ +"""Хранение назначения строк ОСВ правилам по версии консолидации.""" + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op +from src.models.base import SCHEMA + +revision = "0011_dbml_v5_24" +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("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)), + 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", + 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="action_values"), + sa.CheckConstraint( + "(action = 'ASSIGN' AND rule_id IS NOT NULL) OR (action = 'UNASSIGNED' AND rule_id IS NULL)", + name="action_rule_consistency", + ), + sa.CheckConstraint("rule_id IS NULL OR NOT is_exclude", name="rule_not_excluded"), + 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) diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index 121e6ef..067a03a 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 600689a..cfb637d 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -13,9 +13,13 @@ 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 from src.services.elimination_rules import EliminationRuleService from src.services.intercompany_matrix import IntercompanyMatrixService +from src.services.vgo import VgoAssignmentCrudService, VgoService def get_consolidation_service( @@ -97,3 +101,59 @@ 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)] + + +def get_correction_source(session: SessionDep, vgo_service: VgoServiceDep) -> CorrectionAggregateSource: + """Агрегаты из готового автоматического и ручного разнесения ОСВ.""" + return VgoCorrectionSource(session, vgo_service) + + +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)] + + +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)], + 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/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 934c11e..9326fe7 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, @@ -14,6 +15,7 @@ from src.api.endpoints.consolidations import ( periods, status, versions, + vgo_assignment, ) router = APIRouter(tags=["consolidations"]) @@ -23,8 +25,10 @@ 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) 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/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/api/endpoints/consolidations/vgo_assignment.py b/api/src/api/endpoints/consolidations/vgo_assignment.py new file mode 100644 index 0000000..4e09420 --- /dev/null +++ b/api/src/api/endpoints/consolidations/vgo_assignment.py @@ -0,0 +1,83 @@ +"""Подготовка распределения строк ОСВ по статьям КФО.""" + +import uuid + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ( + CorrectionInvalidationServiceDep, + VgoAssignmentCrudServiceDep, + VgoServiceDep, +) +from src.schemas.consolidations import ( + VgoAssignmentAttachRuleIn, + VgoAssignmentCandidatesOut, + VgoAssignmentDetachRuleIn, + VgoAssignmentExcludeIn, + VgoAssignmentOut, +) + +router = APIRouter(prefix="/consolidations/versions") + + +@router.get( + "/{version_id}/vgo-assignments/distribute", + dependencies=[Depends(require("CONSOLIDATION_VIEW"))], + response_model=VgoAssignmentCandidatesOut, + summary="Агрегаты ВГО по правилам и компаниям с ручными назначениями", +) +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, + corrections: CorrectionInvalidationServiceDep, +) -> list[VgoAssignmentOut]: + """Обновляет назначения для указанных ID и создаёт недостающие записи.""" + 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( + "/{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, + corrections: CorrectionInvalidationServiceDep, +) -> list[VgoAssignmentOut]: + """Оставляет или создаёт записи без правила с is_exclude=true.""" + 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( + "/{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/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/domain/vgo_distribution.py b/api/src/domain/vgo_distribution.py new file mode 100644 index 0000000..a3516fa --- /dev/null +++ b/api/src/domain/vgo_distribution.py @@ -0,0 +1,90 @@ +"""Чистый выбор агрегатов и строк ВГО после поиска встречных сумм.""" + +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/__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/models/kfo.py b/api/src/models/kfo.py index 5878668..f5a439e 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,53 @@ 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", + ), + CheckConstraint("rule_id IS NULL OR NOT is_exclude", name="rule_not_excluded"), + ) + + 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", 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") + ) + action: Mapped[str] = mapped_column(String, nullable=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") + ) + 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/corrections.py b/api/src/repositories/corrections.py new file mode 100644 index 0000000..e2d3aec --- /dev/null +++ b/api/src/repositories/corrections.py @@ -0,0 +1,69 @@ +"""Хранение корректировок; транзакция и проверка видимости принадлежат сервису.""" + +import uuid +from collections.abc import Collection + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +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 + + +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/repositories/vgo_assignments.py b/api/src/repositories/vgo_assignments.py new file mode 100644 index 0000000..f56abca --- /dev/null +++ b/api/src/repositories/vgo_assignments.py @@ -0,0 +1,32 @@ +"""Ручные назначения строк ОСВ в версии консолидации.""" + +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 | None = None +) -> list[VgoAssignment]: + """Все ручные решения версии, при необходимости ограниченные типом отчётности.""" + 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 f53eab8..7702cfc 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -425,6 +425,168 @@ class EliminationPairsOut(BaseModel): return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) +class VgoAssignmentRowOut(BaseModel): + """Поля строки свода, необходимые для распределения ВГО.""" + + 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 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): + """Принятые агрегаты и строки без встречной пары одного правила и пары компаний.""" + + 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) + positions: list[VgoAssignmentPositionOut] = Field(default_factory=list) + + @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] + 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): + """Привязать все исходные 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/schemas/corrections.py b/api/src/schemas/corrections.py new file mode 100644 index 0000000..5ded9f0 --- /dev/null +++ b/api/src/schemas/corrections.py @@ -0,0 +1,149 @@ +"""Ответы первого этапа таблицы корректировок; деньги в рублях, 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 + 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): + article_line: str + accounts: list[str] + amount: Money + 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 + + +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 + pairs: list[CorrectionPairOut] = Field(default_factory=list) + 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] + unallocated: list[UnallocatedEntryOut] + 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_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 new file mode 100644 index 0000000..cb4f7f8 --- /dev/null +++ b/api/src/services/correction_source.py @@ -0,0 +1,204 @@ +"""Граница с разнесением ОСВ: адаптация готового результа ``VgoService``.""" + +import uuid +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Literal, Protocol, cast + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +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 ConsolidationVersion +from src.models.enums import StatementType +from src.schemas.consolidations import RefOut, VgoAssignmentGroupOut, VgoUnallocatedPositionOut +from src.schemas.corrections import ( + AggregateSideOut, + CorrectionAggregateOut, + CorrectionEntryOut, + CorrectionPairOut, + CorrectionRuleOut, + UnallocatedEntryOut, +) +from src.services.vgo import VgoService + +ZERO = Decimal("0") +COMPANY_NUMBERS = (1, 2) +Side = Literal["D", "K"] + + +@dataclass(frozen=True) +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): + async def table(self, version_id: uuid.UUID, statement_type: StatementType) -> CorrectionSourceTable: ... + + +@dataclass +class _RuleSpec: + rule: EliminationRule + included_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) + excluded_codes: dict[int, list[str]] = field(default_factory=lambda: {1: [], 2: []}) + + +def _direction(rule: EliminationRule, company_no: int) -> Side: + if rule.side_1 not in ("D", "K"): + raise ConflictError("CORRECTION_SOURCE_INVALID", "В правиле задана неизвестная сторона") + first = cast(Side, rule.side_1) + if company_no == 1: + return first + return "K" if first == "D" else "D" + + +class VgoCorrectionSource: + """Преобразует готовое автоматическое и ручное разнесение в таблицу корректировок.""" + + def __init__(self, session: AsyncSession, vgo_service: VgoService) -> None: + self._session = session + self._vgo_service = vgo_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} + 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 + 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(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 or statement_type != StatementType.balance: + return CorrectionSourceTable() + specs = await self._rules(statement_type, version.elimination_rules_version) + allocation = await self._vgo_service.assignment_candidates(version_id) + 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] + 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: + 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=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])), + company_2_excluded=sorted(set(spec.excluded_codes[2])), + ) + + @staticmethod + def _aggregate(group: VgoAssignmentGroupOut, spec: _RuleSpec) -> CorrectionAggregateOut: + rule = spec.rule + side_1_code = _direction(rule, 1) + 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, + ) + for position in group.positions + ] + + 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=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, + 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..c8bd0b6 --- /dev/null +++ b/api/src/services/corrections.py @@ -0,0 +1,252 @@ +"""Сборка агрегатной таблицы первого этапа; исходные ОСВ не изменяются.""" + +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, 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 + +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 + 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) + 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, + 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), + 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..d726eac --- /dev/null +++ b/api/src/services/corrections_export.py @@ -0,0 +1,129 @@ +"""Чистая XLSX-выгрузка DTO таблицы корректировок, суммы в рублях.""" + +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.worksheet.worksheet import Worksheet + +from src.schemas.corrections import CorrectionAggregateOut, CorrectionEntryOut, CorrectionPairOut, 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 _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() + 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 + 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, "Итого по агрегатам") + 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..c301d26 --- /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: + 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} + 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/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..eaa5c2a --- /dev/null +++ b/api/src/services/vgo.py @@ -0,0 +1,630 @@ +"""Подготовка строк сводной ОСВ для распределения ВГО по правилам.""" + +import uuid +from collections import defaultdict +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 + +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.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 ( + EliminationMatchedPairOut, + VgoAssignmentAttachRuleIn, + VgoAssignmentCandidatesOut, + VgoAssignmentDetachRuleIn, + VgoAssignmentExcludeIn, + VgoAssignmentGroupOut, + VgoAssignmentOut, + VgoAssignmentPositionOut, + VgoAssignmentRowOut, + VgoUnallocatedPositionOut, +) +from src.services.consolidated_osv import ConsolidatedOsvBuildResult, ConsolidatedOsvService +from src.services.consolidation_guard import lock_editable_version +from src.services.intercompany_matrix import IntercompanyMatrixService, _make_account_tree + +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( + 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.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] + partial_rows: set[tuple[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() + 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): + 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) + != (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, 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( + rows: Sequence[ConsolidatedRow], + manual: _ManualResult, + distribution: Distribution, +) -> list[VgoAssignmentRowOut]: + 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 +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, distribution: Distribution +) -> dict[GroupKey, list[EliminationMatchedPairOut]]: + """Сгруппировать выбранные доменной функцией расхождения.""" + diff_aggregates: defaultdict[GroupKey, list[EliminationMatchedPairOut]] = defaultdict(list) + 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 + + +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: + 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, []) + 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) + 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( + 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], + 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: + """Операции ВГО над сводной ОСВ версии консолидации.""" + + 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 _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, + osv_result: ConsolidatedOsvBuildResult, + rules: Sequence[EliminationRule], + rows: Sequence[ConsolidatedRow], + ) -> _AggregateSearchResult: + """Перебрать правила и компании, сразу закрепляя только точные агрегаты.""" + search = _AggregateSearchResult() + 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 + ) + 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 + + 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 = 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) + 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: + 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, osv_result.consolidated.rows, owners + ) + unassigned = _unassigned_rows(decision_rows, manual, distribution) + 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, + ) -> 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_corrections.py b/api/tests/test_api_corrections.py new file mode 100644 index 0000000..4133ded --- /dev/null +++ b/api/tests/test_api_corrections.py @@ -0,0 +1,276 @@ +"""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.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] = {} + 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], + ) + + +@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, + decision="AUTO", + candidate_rule_ids=[], + ) + + 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_automatic_allocation_builds_correction_aggregate( + auth_client: AuthClientFactory, seeded: AsyncSession, fixtures_dir: Path +) -> None: + world = await real_trade(seeded, fixtures_dir) + 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 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 + 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..7187e51 --- /dev/null +++ b/api/tests/test_api_corrections_mutations.py @@ -0,0 +1,354 @@ +"""Сохранение APPLIED поверх реальных сумм ОСВ: замена, валидация и optimistic locking.""" + +import asyncio +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 +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 + + +@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) + # Коды — из настоящих годовых форм 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) + correction_source.calls = 0 + 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" + 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 + 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_api_vgo_assignments.py b/api/tests/test_api_vgo_assignments.py new file mode 100644 index 0000000..d1b05d5 --- /dev/null +++ b/api/tests/test_api_vgo_assignments.py @@ -0,0 +1,347 @@ +"""Ручные операции над назначениями ВГО для полных строк сводной ОСВ.""" + +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 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 +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 +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 == { + ("GET", 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_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)) + 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() + + +@pytest.mark.integration +@pytest.mark.fixtures +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) + 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) + 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: + 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_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_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 new file mode 100644 index 0000000..2a0df61 --- /dev/null +++ b/api/tests/test_correction_source.py @@ -0,0 +1,128 @@ +"""Адаптация готовых позиций VGO в агрегат таблицы корректировок.""" + +import uuid +from decimal import Decimal +from types import SimpleNamespace + +from src.schemas.consolidations import ( + EliminationMatchedPairOut, + VgoAssignmentGroupOut, + VgoAssignmentPositionOut, + VgoUnallocatedPositionOut, +) +from src.services.correction_source import VgoCorrectionSource, _RuleSpec + + +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", + side_1="D", + measure="closing", + elimination_mode="min", + ) + 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)], + ) + ], + 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", + ), + ], + ) + + 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") + 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)] + 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_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..aedf9e6 --- /dev/null +++ b/api/tests/test_corrections_export.py @@ -0,0 +1,136 @@ +"""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"]), + decision="AUTO", + candidate_rule_ids=[], + ) + + 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), + 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=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, + status="AUTO", + eliminated_amount=amount, + correction=None, + ) + ], + unallocated=[], + 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["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: + 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_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_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" 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_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_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_migrations.py b/api/tests/test_migrations.py index 19a8c30..202dda4 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" @@ -1294,7 +1295,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 +1305,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 75c5255..bf47e58 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -76,10 +76,19 @@ 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"}, "/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"}, + "/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 new file mode 100644 index 0000000..8b5b467 --- /dev/null +++ b/api/tests/test_vgo.py @@ -0,0 +1,407 @@ +"""Группировка найденных пар ВГО и единственность исходных строк во всей версии.""" + +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.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 + + +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_id=None, + 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")} + + +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=[])), + ) + 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) + + 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 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() + + +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", + side_1="D", + article_1_line="Статья A1", + article_2_line="Статья B1", + ), + SimpleNamespace( + id=rule_2, + rule_no=2, + statement_type=StatementType.balance, + measure="closing", + side_1="D", + article_1_line="Статья A2", + article_2_line="Статья B2", + ), + ] + 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]) + 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)), + ) + 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] == [ + (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_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)] + 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=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])) + 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("30.00"), Decimal("0.00")) + 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 [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: + 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, free_row]), + members=_members(company_a, company_b), + ) + assignment = SimpleNamespace(osv_entry_id=entry_a, rule_id=rule_id, action="ASSIGN", is_exclude=False) + 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)), + matrix, + ) + + 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 response.unallocated[0].reason == "UNASSIGNED" + 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: + 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 "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) + + +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, []) 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..8323455 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/design.md @@ -0,0 +1,50 @@ +# Design + +## Context + +См. `proposal.md`. В модели уже существует `correction` с дочерними строками и статусами. Разнесение ОСВ по правилам и ручные назначения реализуются в AURORA-1751; этот change вызывает её расчёт через интерфейс источника, но не дублирует алгоритм и не сохраняет вычисленные пары. + +## Goals / Non-Goals + +**Goals:** + +- Адаптировать вычисленные агрегаты AURORA-1751 для контракта таблицы корректировок, сохранив сопоставленные пары. +- Рассчитать контроль отклонения и справочную элиминацию для полученных агрегатов. +- Прочитать и атомарно сохранить корректировку в существующей модели. +- Обеспечить блокировки, optimistic version, аудит и XLSX-экспорт. + +**Non-Goals:** + +- Реализовывать собственное сопоставление строк ОСВ, выбирать правило или хранить вычисленные пары — это + AURORA-1751; DBML v5.24, ORM-модель `VgoAssignment` и миграция `vgo_assignment` в этой ветке сделаны в рамках + 1751, а не этим change (см. Migration Plan). +- Импортировать XLSX, рассчитывать КФО, поддерживать ОФР или UI. + +## Decisions + +1. **Интеграция через `CorrectionAggregateSource`.** Реализация `VgoCorrectionSource` вызывает `VgoService.assignment_candidates` из AURORA-1751. Сервис 1751 при каждом запросе рассчитывает группы, `matched`, `with_discrepancy`, ручные и нераспределённые позиции. Сервис корректировок не читает схему `vgo_assignment` напрямую и не повторяет алгоритм сопоставления. + +2. **Пары остаются вычисляемыми.** Адаптер преобразует `matched` и `with_discrepancy` в `pairs` ответа GET, связывая стороны по `osv_entry_ids_a` и `osv_entry_ids_b`. Пара содержит обе стороны, суммы и отклонение результата 1751. Отдельная ORM-модель или миграция для пар не нужна. + +3. **Чистый расчёт контроля.** Функция домена принимает две суммы в рублях и возвращает отклонение, процент, признак превышения и `eliminated_amount`. Для границы `≤ 5 %` элиминируется `min`; процент вычисляется до округления. При нулевой меньшей стороне процент `null`. + +4. **Использовать существующую `correction`.** Корневая строка хранит сторону/статью и метаданные, дочерние строки — распределение по дополнительным статьям. Запрос сохраняет полный набор заменой в одной транзакции. Новая миграция не нужна. + +5. **Комментарий обязателен всегда.** Проверка выполняется до записи независимо от процента отклонения. Успешное сохранение сразу даёт `APPLIED`; отдельная операция подтверждения первого этапа не нужна. + +6. **Конкурентность.** Перед записью блокируется версия и проверяется её владелец. `expected_version_no` защищает от потери параллельной правки. Ошибка любой строки откатывает весь набор и аудит. + +7. **Экспорт строится из DTO GET.** XLSX не пересчитывает агрегаты и использует один лист. Каждая вычисленная пара следует за агрегатом одной строкой outline level 1 с заполненными сторонами обеих компаний. Её позиции повторно не выводятся. Ручные и односторонние позиции остаются отдельными строками. Колонка «Правило» у детализации пустая. Полное русское имя передаётся через `filename*`, ASCII fallback сохраняется для совместимости. + +## Risks / Trade-offs + +- [Результат AURORA-1751 изменит внутреннее представление] → зависимость изолирована интерфейсом `CorrectionAggregateSource` и адаптером `VgoCorrectionSource`. +- [Точный состав агрегата требует дорогого полного распределения] → после ASSIGN/UNASSIGN все `APPLIED`-корректировки + пары компаний консервативно переводятся в `DRAFT` по данным `osv_entry`; `REVOKED` без точного состава не используется. +- [Ровно 5 % ранее трактовалось как превышение] → зафиксировать `≤ 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/proposal.md b/openspec/changes/corrections-api-stage-one/proposal.md new file mode 100644 index 0000000..c1d2467 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +Аналитику нужна агрегатная таблица корректировок ББ: видеть исходные суммы уже сформированных агрегатов, контролировать отклонение и сохранять обоснованную корректировку по статьям баланса. Текущий backend не предоставляет единого API для этого сценария. + +## What Changes + +- Добавить сборку агрегатной таблицы корректировок ББ из результата разнесения AURORA-1751 через `CorrectionAggregateSource`: исходные суммы, вычисленные сопоставленные пары, отклонение к меньшей положительной стороне и справочная сумма элиминации `min` в пределах допуска. +- Добавить атомарное сохранение корректировки агрегата в существующую модель `correction`; комментарий обязателен при каждом сохранении, результат получает статус `APPLIED`. +- Поддержать дочерние статьи обеих сторон, контроль редакции, блокировку версии, запрет изменения после верификации и аудит `CORRECTION_CREATE/EDIT`. +- Добавить XLSX-экспорт одним листом: агрегат, затем раскрываемые сопоставленные пары и оставшиеся односторонние или ручные позиции, итоговые суммы. +- Использовать рубли и `Decimal`; процент считать к меньшей положительной сумме до экранного округления. + +## Capabilities + +### New Capabilities + +- `corrections-api`: чтение, сохранение и экспорт агрегатных корректировок ББ. + +### Modified Capabilities + +Нет. + +## Impact + +Затрагивается `arm-core`: адаптер вычисляемого результата AURORA-1751, endpoints, schemas, domain-расчёт, service/repository для существующей таблицы `correction`, аудит и XLSX-экспорт. Новая модель данных для пар не создаётся. + +## Non-goals + +- Алгоритм разнесения ОСВ, ручной перенос и исключение — ответственность 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 new file mode 100644 index 0000000..6fbc6c7 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/specs/corrections-api/spec.md @@ -0,0 +1,98 @@ +# Spec Delta + +## Purpose + +Предоставляет backend-контракт для просмотра, сохранения и экспорта корректировок агрегатов бухгалтерского баланса, вычисленных сервисом разнесения ОСВ. + +## ADDED Requirements + +### Requirement: Чтение таблицы корректировок +Система SHALL вызывать сервис разнесения AURORA-1751 через `CorrectionAggregateSource` и возвращать его агрегаты ББ с компаниями, статьями, счетами, позициями, сопоставленными парами, суммами, отклонением и сохранённой корректировкой. GET MUST NOT записывать вычисленное разнесение, пары или корректировки в БД. + +#### Scenario: Чтение доступной версии +- **WHEN** пользователь с правом просмотра запрашивает таблицу доступной версии +- **THEN** система возвращает вычисленные агрегаты, сопоставленные пары и оставшиеся позиции в стабильном порядке +- **THEN** в БД не создаются назначения или корректировки + +#### Scenario: Сопоставленная пара +- **WHEN** сервис AURORA-1751 возвращает элемент в `matched` или `with_discrepancy` +- **THEN** агрегат содержит `pairs` с обеими сторонами этого элемента +- **THEN** каждая сторона пары содержит относящиеся к ней идентификаторы строк ОСВ, счета и сумму +- **THEN** суммы и отклонение пары совпадают с результатом AURORA-1751 без повторного сопоставления в сервисе корректировок + +#### Scenario: Позиция без сопоставленной стороны +- **WHEN** ручная или односторонняя позиция агрегата не входит ни в одну вычисленную пару +- **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 быть пустой. Каждая сопоставленная пара SHALL занимать одну строку с одновременно заполненными колонками обеих компаний. + +#### Scenario: Экспорт ББ +- **WHEN** пользователь экспортирует таблицу корректировок +- **THEN** файл называется `Корректировки_<название ГК>_<год>_<квартал>.xlsx` +- **THEN** книга содержит один лист с агрегатами, детализацией и итогами в рублях +- **THEN** позиции сопоставленной пары не дублируются отдельными строками под той же парой +- **THEN** ручные и односторонние позиции выводятся отдельными строками +- **THEN** система создаёт событие аудита `EXPORT` + +### Requirement: Граница этапа +Система MUST NOT предоставлять в этом capability импорт XLSX, собственный алгоритм разнесения строк ОСВ, ручной перенос строк между правилами или операции изменения назначений. Вызов готового расчёта AURORA-1751 для чтения таблицы SHALL считаться интеграцией с источником, а не реализацией разнесения в capability корректировок. + +#### 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 new file mode 100644 index 0000000..8e0f990 --- /dev/null +++ b/openspec/changes/corrections-api-stage-one/tasks.md @@ -0,0 +1,41 @@ +# Tasks + +## 1. Граница агрегатов и расчёт + +- [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] Передать из результата AURORA-1751 в контракт корректировок нераспределённые позиции: `unallocated`, `decision`, `candidate_rule_ids` и `reason`; сервис корректировок не вычисляет эти значения самостоятельно. + +## 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 с обновлённой спецификацией: сохранить `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.2 [arm-core] Выполнить `openspec validate corrections-api-stage-one --strict`, целевые unit/integration-тесты, `make check` и `make dbml-check`; зафиксировать фактические результаты без ссылок на тесты старого объёма. + +## 5. Интеграция с распределением ОСВ + +- [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 + +- `openspec validate corrections-api-stage-one --strict` — успешно. +- `ruff check` и `ruff format` изменённых файлов — успешно. +- `mypy` схемы, источника и экспорта корректировок — успешно. +- Целевые тесты источника, экспорта, VGO и OpenAPI — 90 успешно. +- Расширенный набор с API-тестами остановлен на зависшей инициализации интеграционных фикстур; завершившиеся целевые тесты ошибок не показали. +- `make dbml-check` **не выполнялся**: `docs/model/data-model-v5.24.dbml` в `arm-ka-docs` ещё не существует (вопрос поднят отдельно, вне этого PR). + +## 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)