diff --git a/README.md b/README.md index adfc40e..051b398 100644 --- a/README.md +++ b/README.md @@ -796,7 +796,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` ### Ограничения Среза 4 - Маршрут согласования (`CONSOLIDATION_RF` и далее), «Добавить версию», `CONFIRM`, `EXTENDED`, гудвилл — Срезы 6–7; - шахматка и ВГО — Срез 5; ББ/ОФР в своде не участвуют; UI — отдельный исполнитель. + API шахматки, правил элиминации и результирующей таблицы ВГО реализован в Срезе 5; автоматическое сопоставление ВГО, + корректировки и UI — отдельная работа. ББ/ОФР в своде не участвуют. - Открытые вопросы — `../docs/context/07-open-questions.md`: OQ-118 (запрет загрузки/подтверждения после `DATA_CORRECTION`), OQ-119 (истечение блокировки), OQ-120 (`period_check`), OQ-121 (порог размера свода), OQ-122 (ячейка `PROCESSING`), OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного @@ -805,6 +806,15 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` - Тест на реальных данных — `api/tests/test_consolidation_fixtures.py` (маркер `fixtures`): ОСВ РБ и Счастливочной за Q1–Q3 2024 → версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам. +## Шахматка и результирующая таблица ВГО (Срез 5) + +API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица +`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица +`GET …/intercompany-matrix/result-table`. Контракт для фронта — +`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях +`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. +Расчёт читает сводную ОСВ версии, результат не хранится. + ## Ошибки API Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: @@ -882,6 +892,11 @@ email без учёта регистра проверяет только сер | `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе» | | `CONSOLIDATION_NOT_EDITABLE` | 409 | загрузка в консолидацию без версии «в работе» | | `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки | +| `ACCOUNT_NOT_FOUND` | 422 | MANUAL шахматки или её экспорта: выбранного счёта нет в плане счетов | +| `ACCOUNT_KIND_MISMATCH` | 422 | MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне | +| `ACCOUNTS_REQUIRED` | 422 | сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела | +| `RESULT_ACCOUNTS_MISSING` | 422 | результирующая таблица ВГО: в плане счетов нет обязательных кодов статей | +| `ACCOUNTS_NOT_ALLOWED` / `INVALID_MODE` | 422 | защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки | | `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | | `VERSION_NOT_EDITABLE` | 409 | версия не «в работе» (правки) либо `CANCELLED` при «взять в работу» | | `LOCK_REQUIRED` | 409 | правка версии без своей активной блокировки | diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py index fdf0e56..600689a 100644 --- a/api/src/api/dependencies/consolidations.py +++ b/api/src/api/dependencies/consolidations.py @@ -14,6 +14,8 @@ from src.services.consolidation_locks import ConsolidationLockService from src.services.consolidation_status import ConsolidationStatusService from src.services.consolidations import ConsolidationService from src.services.counterparty_mapping import CounterpartyMappingService +from src.services.elimination_rules import EliminationRuleService +from src.services.intercompany_matrix import IntercompanyMatrixService def get_consolidation_service( @@ -73,3 +75,25 @@ def get_counterparty_mapping_service( CounterpartyMappingServiceDep = Annotated[CounterpartyMappingService, Depends(get_counterparty_mapping_service)] + + +def get_elimination_rule_service( + session: SessionDep, scope: Annotated[VisibilityScope, Depends(get_scope)] +) -> EliminationRuleService: + """Сервис чтения правил элиминации из БД для версии консолидации.""" + return EliminationRuleService(session, scope) + + +EliminationRuleServiceDep = Annotated[EliminationRuleService, Depends(get_elimination_rule_service)] + + +def get_intercompany_matrix_service( + session: SessionDep, + scope: Annotated[VisibilityScope, Depends(get_scope)], + osv_service: ConsolidatedOsvServiceDep, +) -> IntercompanyMatrixService: + """Сервис отчёта intercompany-matrix (шахматки).""" + return IntercompanyMatrixService(session, scope, osv_service) + + +IntercompanyMatrixServiceDep = Annotated[IntercompanyMatrixService, Depends(get_intercompany_matrix_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py index 3521738..934c11e 100644 --- a/api/src/api/endpoints/consolidations/__init__.py +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -2,7 +2,19 @@ from fastapi import APIRouter -from src.api.endpoints.consolidations import checklist, export, locks, mapping, members, osv, periods, status, versions +from src.api.endpoints.consolidations import ( + checklist, + elimination_rule, + export, + intercompany_matrix, + locks, + mapping, + members, + osv, + periods, + status, + versions, +) router = APIRouter(tags=["consolidations"]) router.include_router(periods.router) @@ -14,3 +26,5 @@ router.include_router(checklist.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) diff --git a/api/src/api/endpoints/consolidations/elimination_rule.py b/api/src/api/endpoints/consolidations/elimination_rule.py new file mode 100644 index 0000000..66af95f --- /dev/null +++ b/api/src/api/endpoints/consolidations/elimination_rule.py @@ -0,0 +1,33 @@ +"""Правила элиминации для версии консолидации.""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import EliminationRuleServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import EliminationRuleOut + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}/elimination-rules", + response_model=list[EliminationRuleOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Правила для шахматки", +) +async def list_rules( + version_id: uuid.UUID, + service: EliminationRuleServiceDep, +) -> list[EliminationRuleOut]: + """Все правила выбранной версии со счетами и исключениями обеих сторон.""" + return await service.list_rules(version_id) diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py new file mode 100644 index 0000000..2fd695a --- /dev/null +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -0,0 +1,160 @@ +"""Расчёт шахматки по всем или выбранным счетам.""" + +import uuid +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Response, status +from starlette.concurrency import run_in_threadpool + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import IntercompanyMatrixServiceDep +from src.common.filenames import content_disposition +from src.models.enums import AuditAction, AuditSection +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut +from src.services.intercompany_matrix import MatrixExportInfo +from src.services.intercompany_matrix_export import build_matrix_workbook + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +ERRORS: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"}, +} +XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +EXPORT_RESPONSES = { + **ERRORS, + status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}, +} + + +async def _xlsx_response(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> Response: + content = await run_in_threadpool(build_matrix_workbook, matrix, info) + return Response( + content=content, + media_type=XLSX_MEDIA_TYPE, + headers={"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-matrix.xlsx")}, + ) + + +async def _export_response( + version_id: uuid.UUID, + matrix: IntercompanyMatrixOut, + info: MatrixExportInfo, + audit: RequestAudit, + *, + selection: IntercompanyMatrixManualIn | None = None, +) -> Response: + response = await _xlsx_response(matrix, info) + 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": "intercompany_matrix", + "mode": info.mode, + "debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [], + "credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [], + "cells": len(matrix.cells), + }, + ) + return response + + +@router.get( + "/{version_id}/intercompany-matrix/all", + response_model=IntercompanyMatrixOut, + dependencies=VIEW, + responses=ERRORS, + summary="Рассчитать шахматку по всем счетам", +) +async def build_intercompany_matrix_all( + version_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, +) -> IntercompanyMatrixOut: + """Все счета с подходящим видом дебета или кредита.""" + return await service.build_accounts(version_id, mode="ALL") + + +@router.get( + "/{version_id}/intercompany-matrix/all/export", + response_class=Response, + dependencies=VIEW, + responses=EXPORT_RESPONSES, + summary="Скачать шахматку по всем счетам в XLSX", +) +async def export_intercompany_matrix_all( + version_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> Response: + matrix, info = await service.build_export(version_id, mode="ALL") + return await _export_response(version_id, matrix, info, audit) + + +@router.post( + "/{version_id}/intercompany-matrix/manual", + response_model=IntercompanyMatrixOut, + dependencies=VIEW, + responses=ERRORS, + summary="Рассчитать шахматку по выбранным счетам", +) +async def build_intercompany_matrix_manual( + version_id: uuid.UUID, + selection: IntercompanyMatrixManualIn, + service: IntercompanyMatrixServiceDep, +) -> IntercompanyMatrixOut: + """Выбранные счета раскрываются по иерархии.""" + return await service.build_accounts( + version_id, + mode="MANUAL", + debit_account_ids=selection.debit_account_ids, + credit_account_ids=selection.credit_account_ids, + ) + + +@router.post( + "/{version_id}/intercompany-matrix/manual/export", + response_class=Response, + dependencies=VIEW, + responses=EXPORT_RESPONSES, + summary="Скачать шахматку по выбранным счетам в XLSX", +) +async def export_intercompany_matrix_manual( + version_id: uuid.UUID, + selection: IntercompanyMatrixManualIn, + service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> Response: + matrix, info = await service.build_export( + version_id, + mode="MANUAL", + debit_account_ids=selection.debit_account_ids, + credit_account_ids=selection.credit_account_ids, + ) + return await _export_response( + version_id, + matrix, + info, + audit, + selection=selection, + ) + + +@router.get( + "/{version_id}/intercompany-matrix/result-table", + response_model=IntercompanyResultTableOut, + dependencies=VIEW, + responses=ERRORS, + summary="Результирующая таблица ВГО", +) +async def get_intercompany_result_table( + version_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, +) -> IntercompanyResultTableOut: + """Пять фиксированных статей: суммы по компаниям и итоги по группе.""" + return await service.build_result_table(version_id) diff --git a/api/src/api/endpoints/directories/__init__.py b/api/src/api/endpoints/directories/__init__.py index 9454158..d8972aa 100644 --- a/api/src/api/endpoints/directories/__init__.py +++ b/api/src/api/endpoints/directories/__init__.py @@ -2,10 +2,18 @@ from fastapi import APIRouter -from src.api.endpoints.directories import company_groups, import_, legal_entities, regional_offices, related_groups +from src.api.endpoints.directories import ( + accounts, + company_groups, + import_, + legal_entities, + regional_offices, + related_groups, +) router = APIRouter(prefix="/directories", tags=["directories"]) router.include_router(regional_offices.router) +router.include_router(accounts.router) router.include_router(company_groups.router) router.include_router(legal_entities.router) router.include_router(related_groups.router) diff --git a/api/src/api/endpoints/directories/accounts.py b/api/src/api/endpoints/directories/accounts.py new file mode 100644 index 0000000..eb0b912 --- /dev/null +++ b/api/src/api/endpoints/directories/accounts.py @@ -0,0 +1,21 @@ +"""Плоский список счетов плана счетов для выбора статьи расходов.""" + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.db import SessionDep +from src.repositories import directories as repo +from src.schemas.directories import AccountOut + +router = APIRouter() + + +@router.get( + "/accounts", + response_model=list[AccountOut], + dependencies=[Depends(require("DIRECTORY_VIEW"))], + summary="Список счетов плана счетов", +) +async def list_accounts(session: SessionDep) -> list[AccountOut]: + """Все счета одним списком""" + return [AccountOut.model_validate(account) for account in await repo.list_accounts(session)] diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py index 8e6eb62..abb5ae7 100644 --- a/api/src/domain/consolidated_osv.py +++ b/api/src/domain/consolidated_osv.py @@ -101,6 +101,7 @@ class SourceRow: turnover_credit: Decimal | None closing_debit: Decimal | None closing_credit: Decimal | None + account_id: uuid.UUID | None = None @dataclass(frozen=True) @@ -132,6 +133,7 @@ class ConsolidatedRow: closing_debit: Decimal | None closing_credit: Decimal | None warnings: tuple[str, ...] = () + account_id: uuid.UUID | None = None @dataclass(frozen=True) @@ -342,6 +344,7 @@ def _compose( # noqa: PLR0913 - сборка строки свода из ча legal_entity_inn=entity.inn, account_code=representative.account_code, account_root=representative.account_root, + account_id=representative.account_id, period=context.period, is_cumulative=is_cumulative, row_number=representative.row_number, diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index 8881ee7..a475b57 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -14,7 +14,9 @@ from sqlalchemy import Select, Sequence, and_, delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.directory_import import DirectorySnapshot, EntitySnap, GroupSnap, OfficeSnap +from src.domain.osv_mapping import account_sort_key from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible +from src.models.accounting import ChartOfAccount from src.models.directories import ( CompanyGroup, CompanyGroupHistory, @@ -32,6 +34,11 @@ LIKE_ESCAPE = "\\" ALIAS_LOCK_PREFIX = "legal-entity-alias" # пространство ключей advisory-блокировки алиасов +async def list_accounts(session: AsyncSession) -> list[ChartOfAccount]: + accounts = (await session.scalars(select(ChartOfAccount))).all() + return sorted(accounts, key=lambda account: (account_sort_key(account.code), account.code)) + + def escape_like(text: str) -> str: """Экранирует `\\`, `%` и `_`, чтобы пользовательская строка искалась как подстрока, а не как шаблон LIKE.""" return text.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2).replace("%", f"{LIKE_ESCAPE}%").replace("_", f"{LIKE_ESCAPE}_") diff --git a/api/src/repositories/elimination_rules.py b/api/src/repositories/elimination_rules.py new file mode 100644 index 0000000..aa0389a --- /dev/null +++ b/api/src/repositories/elimination_rules.py @@ -0,0 +1,53 @@ +"""Чтение версионированных правил элиминации и счетов их сторон.""" + +import uuid +from collections.abc import Collection + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount +from src.models.consolidation import ConsolidationVersion + + +async def list_rules_with_accounts( + session: AsyncSession, consolidation_version_id: uuid.UUID +) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: + """Правила версии консолидации по `elimination_rules_version` и счета их сторон.""" + stmt = ( + select(EliminationRule, EliminationRuleAccount) + .join(ConsolidationVersion, ConsolidationVersion.elimination_rules_version == EliminationRule.version_no) + .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) + .where(ConsolidationVersion.id == consolidation_version_id) + .order_by( + EliminationRule.statement_type, + EliminationRule.rule_no, + EliminationRuleAccount.company_no, + ) + ) + return [(rule, account) for rule, account in (await session.execute(stmt)).all()] + + +async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: + """Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера.""" + stmt = ( + select(EliminationRule) + .where(EliminationRule.version_no == version_no) + .order_by(EliminationRule.statement_type, EliminationRule.rule_no) + ) + return list((await session.scalars(stmt)).all()) + + +async def list_rule_accounts( + session: AsyncSession, rule_ids: Collection[uuid.UUID] +) -> list[tuple[EliminationRuleAccount, str]]: + """Счета правил с кодами из плана счетов; `company_no` задаёт сторону, `is_excluded` — исключение.""" + if not rule_ids: + return [] + stmt = ( + select(EliminationRuleAccount, ChartOfAccount.code) + .join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id) + .where(EliminationRuleAccount.rule_id.in_(rule_ids)) + .order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code) + ) + return [(account, code) for account, code in (await session.execute(stmt)).all()] diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 7c7eddf..adde35c 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -8,7 +8,8 @@ import datetime import uuid -from decimal import Decimal +from decimal import ROUND_HALF_UP, Decimal +from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -22,6 +23,7 @@ from src.models.enums import ( DocumentType, EntityType, PeriodQuarter, + StatementType, ) YEAR_MIN = 2000 @@ -304,3 +306,91 @@ class MapCounterpartyOut(BaseModel): propagated: int alias_created: bool alias_moved_from: uuid.UUID | None + + +class IntercompanyMatrixRuleSideOut(BaseModel): + """Счета одной стороны правила intercompany-matrix (шахматки) до раскрытия иерархии.""" + + accounts: list[uuid.UUID] + excluded: list[uuid.UUID] + + +class EliminationRuleOut(BaseModel): + """Правило элиминации из БД; ключ — тип отчётности, версия и номер.""" + + rule_no: int + version_no: int + statement_type: StatementType + side_1: Literal["D", "K"] + measure: Literal["closing", "turnover"] + company_1: IntercompanyMatrixRuleSideOut + company_2: IntercompanyMatrixRuleSideOut + + +class IntercompanyMatrixManualIn(BaseModel): + """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" + + debit_account_ids: list[uuid.UUID] = Field(min_length=1) + credit_account_ids: list[uuid.UUID] = Field(min_length=1) + + +class IntercompanyMatrixCellOut(BaseModel): + """Суммы и расхождение для пары компаний A и Б.""" + + company_a_id: uuid.UUID + company_b_id: uuid.UUID + amount_a: Decimal + amount_b: Decimal + variance_amount: Decimal + variance_percent: Decimal | None + exceeds_threshold: bool + one_sided: bool + skipped_osv_entry_count: int = Field( + ge=0, + description="Число исходных строк ОСВ с account_id=null у обеих компаний пары; в суммы они не вошли", + ) + osv_entry_ids_debit: list[uuid.UUID] + osv_entry_ids_credit: list[uuid.UUID] + + @field_validator("amount_a", "amount_b", "variance_amount", "variance_percent") + @classmethod + def _two_decimals(cls, value: Decimal | None) -> Decimal | None: + """Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null.""" + return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP) + + +class IntercompanyMatrixCompanyOut(BaseModel): + """Компания на обеих осях шахматки.""" + + id: uuid.UUID + name: str + + +class IntercompanyMatrixOut(BaseModel): + """Компании задают порядок обеих осей; диагональные ячейки отсутствуют.""" + + companies: list[IntercompanyMatrixCompanyOut] + cells: list[IntercompanyMatrixCellOut] + + +class IntercompanyResultAmountOut(BaseModel): + """Сумма статьи у компании по всем остальным компаниям периметра.""" + + company_id: uuid.UUID + amount: Decimal + + +class IntercompanyResultArticleOut(BaseModel): + """Статья ВГО и её суммы по компаниям и группе.""" + + code: str + name: str + amounts: list[IntercompanyResultAmountOut] + group_total: Decimal + + +class IntercompanyResultTableOut(BaseModel): + """Фиксированная результирующая таблица ВГО выбранной версии.""" + + companies: list[IntercompanyMatrixCompanyOut] + articles: list[IntercompanyResultArticleOut] diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index 53d827a..a376362 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -6,7 +6,7 @@ from typing import Annotated, Literal, Self from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator -from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind +from src.models.enums import AccountKind, AccountRowType, EntityType, LeHistoryEvent, RelatedGroupKind NAME_MAX_LENGTH = 500 MAX_ALIASES = 100 # алиасов в одном теле формы ЮЛ (#84): граница входа, 422 до обращения к БД @@ -38,6 +38,32 @@ class RegionalOfficeOut(RegionalOfficeBrief): is_active: bool +class AccountOut(BaseModel): + """Одна строка плана счетов; parent_id сохраняет иерархию в плоском списке.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + code: str + parent_id: uuid.UUID | None + name: str + kind: AccountKind | None + row_type: AccountRowType + is_off_balance: bool + + @computed_field # type: ignore[prop-decorator] + @property + def is_debit(self) -> bool: + """Счёт можно выбрать для дебетовой стороны: активный или активно-пассивный.""" + return self.kind in (AccountKind.A, AccountKind.AP) + + @computed_field # type: ignore[prop-decorator] + @property + def is_credit(self) -> bool: + """Счёт можно выбрать для кредитовой стороны: пассивный или активно-пассивный.""" + return self.kind in (AccountKind.P, AccountKind.AP) + + class CompanyGroupCreate(BaseModel): """Создание ГК: `code` и `valid_from` назначает сервис.""" diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index f47dc20..4e6542b 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -94,6 +94,7 @@ def _source_row(entry: OsvEntry) -> domain.SourceRow: row_number=entry.row_number, account_code=entry.account_code, account_root=entry.account_root, + account_id=entry.account_id, counterparty_name=entry.counterparty_name, counterparty_inn=entry.counterparty_inn, counterparty_id=entry.counterparty_id, @@ -135,8 +136,15 @@ class ConsolidatedOsvService: self._session = session self._scope = scope - async def _build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: + async def build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]: """Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404.""" + consolidated, version, consolidation, _ = await self.build_with_members(version_id) + return consolidated, version, consolidation + + async def build_with_members( + self, version_id: uuid.UUID + ) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation, dict[uuid.UUID, domain.Entity]]: + """Свод и участники периметра, загруженные при его расчёте.""" found = await repo.get_version(self._session, self._scope, version_id) if found is None: raise NotFoundError(VERSION_NOT_FOUND) @@ -166,11 +174,11 @@ class ConsolidatedOsvService: rows=rows, resolver=index, ) - return consolidated, version, consolidation + return consolidated, version, consolidation, members async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]: """Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16).""" - consolidated, _, _ = await self._build(version_id) + consolidated, _, _ = await self.build(version_id) rows = domain.apply_filter(consolidated.rows, domain_filter(f)) window = rows[page.offset : page.offset + page.limit] return Page[ConsolidatedRowOut]( @@ -181,7 +189,7 @@ class ConsolidatedOsvService: """xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v.xlsx` (без percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса.""" - consolidated, version, consolidation = await self._build(version_id) + consolidated, version, consolidation = await self.build(version_id) rows = domain.apply_filter(consolidated.rows, domain_filter(f)) content = await run_in_threadpool(build_workbook, consolidated, rows) group_name = ( @@ -208,7 +216,7 @@ class ConsolidatedOsvService: async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut: """Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы.""" - consolidated, _, _ = await self._build(version_id) + consolidated, _, _ = await self.build(version_id) rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated) return OsvSummaryOut( rows_total=rows_total, @@ -221,7 +229,7 @@ class ConsolidatedOsvService: async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: """Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом или документ не выбран источником при наличии нарастающего).""" - consolidated, _, _ = await self._build(version_id) + consolidated, _, _ = await self.build(version_id) row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None) return None if row is None else row_out(row) diff --git a/api/src/services/elimination_rules.py b/api/src/services/elimination_rules.py new file mode 100644 index 0000000..5e7191d --- /dev/null +++ b/api/src/services/elimination_rules.py @@ -0,0 +1,51 @@ +"""Чтение правил элиминации для версии консолидации.""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import NotFoundError +from src.domain.policy import VisibilityScope +from src.repositories import consolidations as consolidations_repo +from src.repositories import elimination_rules as rules_repo +from src.schemas.consolidations import EliminationRuleOut, IntercompanyMatrixRuleSideOut + +VERSION_NOT_FOUND = "версия консолидации не найдена" + + +class EliminationRuleService: + """Правила выбранной версии из БД с учетом видимости консолидации.""" + + def __init__(self, session: AsyncSession, scope: VisibilityScope) -> None: + self._session = session + self._scope = scope + + async def list_rules(self, consolidation_version_id: uuid.UUID) -> list[EliminationRuleOut]: + found = await consolidations_repo.get_version(self._session, self._scope, consolidation_version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + + rows = await rules_repo.list_rules_with_accounts(self._session, consolidation_version_id) + by_id: dict[uuid.UUID, EliminationRuleOut] = {} + for rule, account in rows: + result = by_id.get(rule.id) + if result is None: + result = EliminationRuleOut.model_validate( + { + "rule_no": rule.rule_no, + "version_no": rule.version_no, + "statement_type": rule.statement_type, + "side_1": rule.side_1, + "measure": rule.measure, + "company_1": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]), + "company_2": IntercompanyMatrixRuleSideOut(accounts=[], excluded=[]), + } + ) + by_id[rule.id] = result + + if account is None: + continue + side = result.company_1 if account.company_no == 1 else result.company_2 + (side.excluded if account.is_excluded else side.accounts).append(account.account_id) + + return list(by_id.values()) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py new file mode 100644 index 0000000..f14db8c --- /dev/null +++ b/api/src/services/intercompany_matrix.py @@ -0,0 +1,376 @@ +"""Расчёт шахматки по готовой сводной ОСВ и выбранным наборам счетов.""" + +import uuid +from collections import defaultdict +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from decimal import Decimal +from typing import Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import DomainValidationError +from src.domain.consolidated_osv import ConsolidatedRow, Entity +from src.domain.policy import VisibilityScope +from src.models.accounting import ChartOfAccount +from src.models.consolidation import Consolidation, ConsolidationVersion +from src.models.directories import CompanyGroup +from src.models.enums import AccountKind, PeriodQuarter +from src.repositories import directories as directory_repo +from src.schemas.consolidations import ( + IntercompanyMatrixCellOut, + IntercompanyMatrixCompanyOut, + IntercompanyMatrixOut, + IntercompanyResultAmountOut, + IntercompanyResultArticleOut, + IntercompanyResultTableOut, +) +from src.services.consolidated_osv import ConsolidatedOsvService + +Measure = Literal["closing", "turnover"] +Side = Literal["D", "K"] +ZERO = Decimal("0") +VARIANCE_THRESHOLD_PERCENT = Decimal("5") +DEBIT_KINDS = frozenset({AccountKind.A, AccountKind.AP}) +CREDIT_KINDS = frozenset({AccountKind.P, AccountKind.AP}) + + +@dataclass(frozen=True) +class ResultArticleSpec: + code: str + name: str + side: Side + account_codes: tuple[str, ...] + excluded_codes: tuple[str, ...] = () + + +RESULT_ARTICLES = ( + ResultArticleSpec( + "receivables", + "Дебиторская задолженность", + "D", + ("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"), + ("76.07.5", "76.07.9"), + ), + ResultArticleSpec( + "payables", + "Кредиторская задолженность", + "K", + ("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"), + ("76.07.1",), + ), + ResultArticleSpec("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")), + ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")), + ResultArticleSpec("short_term_loans", "Краткосрочные кредиты и займы", "K", ("66", "66.03", "66.04")), +) + + +@dataclass(frozen=True) +class MatrixSelection: + """Счета дебетовой и кредитовой сторон и показатель выбранного режима.""" + + company_1_account_ids: frozenset[uuid.UUID] + company_2_account_ids: frozenset[uuid.UUID] + measure: Measure + + +@dataclass(frozen=True) +class MatrixExportInfo: + group_name: str + year: int + quarter: PeriodQuarter + version_no: int + mode: Literal["ALL", "MANUAL"] + debit_account_codes: tuple[str, ...] + credit_account_codes: tuple[str, ...] + + @property + def filename(self) -> str: + return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" + + +def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: + if measure == "closing": + value = row.closing_debit if side == "D" else row.closing_credit + else: + value = row.turnover_debit if side == "D" else row.turnover_credit + return value if value is not None else ZERO + + +def calculate_matrix( + rows: Iterable[ConsolidatedRow], + member_companies: Sequence[IntercompanyMatrixCompanyOut], + selection: MatrixSelection, +) -> IntercompanyMatrixOut: + """Суммы по парам компаний; + + Сторона 1: дебет ОСВ компании A по контрагенту Б. + Сторона 2: кредит ОСВ компании Б по контрагенту A. + """ + if selection.measure not in ("closing", "turnover"): + raise ValueError(f"неизвестный показатель правила: {selection.measure}") + + company_info = tuple({company.id: company for company in member_companies}.values()) + company_ids = tuple(company.id for company in company_info) + members = set(company_ids) + amounts_1: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()}) + amounts_2: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()}) + skipped_entries: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int) + + for row in rows: + owner = row.legal_entity_id + counterparty = row.counterparty_id + if owner not in members or counterparty not in members or owner == counterparty: + continue + pair = (owner, counterparty) + if row.account_id is None: + skipped_entries[pair] += len(row.entry_ids) + continue + if row.account_id in selection.company_1_account_ids: + item = amounts_1[pair] + item["amount"] += _amount(row, selection.measure, "D") + item["entry_osv_ids"].update(row.entry_ids) + if row.account_id in selection.company_2_account_ids: + item = amounts_2[pair] + item["amount"] += _amount(row, selection.measure, "K") + item["entry_osv_ids"].update(row.entry_ids) + + cells: list[IntercompanyMatrixCellOut] = [] + for company_a in company_ids: + for company_b in company_ids: + if company_a == company_b: + continue + item_a = amounts_1.get((company_a, company_b)) + item_b = amounts_2.get((company_b, company_a)) + + amount_a = item_a["amount"] if item_a is not None else ZERO + amount_b = item_b["amount"] if item_b is not None else ZERO + + variance_amount = abs(amount_a - amount_b) + base = min(amount_a, amount_b) + variance_percent = None if base == ZERO else variance_amount / base * 100 + exceeds_threshold = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT + debit_ids = item_a["entry_osv_ids"] if item_a is not None else () + credit_ids = item_b["entry_osv_ids"] if item_b is not None else () + cells.append( + IntercompanyMatrixCellOut( + company_a_id=company_a, + company_b_id=company_b, + amount_a=amount_a, + amount_b=amount_b, + variance_amount=variance_amount, + variance_percent=variance_percent, + exceeds_threshold=exceeds_threshold, + one_sided=bool(debit_ids) != bool(credit_ids), + skipped_osv_entry_count=skipped_entries[(company_a, company_b)] + + skipped_entries[(company_b, company_a)], + osv_entry_ids_debit=debit_ids, + osv_entry_ids_credit=credit_ids, + ) + ) + return IntercompanyMatrixOut(companies=list(company_info), cells=cells) + + +def _account_tree( + accounts: Sequence[ChartOfAccount], +) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]: + by_id = {account.id: account for account in accounts} + children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) + for account in accounts: + if account.parent_id is not None: + children[account.parent_id].append(account.id) + return by_id, children + + +def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID]]) -> set[uuid.UUID]: + """Выбранные счета и все их потомки.""" + result: set[uuid.UUID] = set() + pending = list(roots) + while pending: + account_id = pending.pop() + if account_id not in result: + result.add(account_id) + pending.extend(children.get(account_id, ())) + return result + + +def _kinds(side: Side) -> frozenset[AccountKind]: + return DEBIT_KINDS if side == "D" else CREDIT_KINDS + + +def _selected_accounts( + included: Iterable[uuid.UUID], + excluded: Iterable[uuid.UUID], + side: Side, + by_id: dict[uuid.UUID, ChartOfAccount], + children: dict[uuid.UUID, list[uuid.UUID]], +) -> frozenset[uuid.UUID]: + roots = set(included) + missing = roots - by_id.keys() + if missing: + missing_ids = ", ".join(map(str, sorted(missing))) + raise DomainValidationError("ACCOUNT_NOT_FOUND", f"Выбранного счёта нет ({missing_ids})") + if any(by_id[account_id].kind not in _kinds(side) for account_id in roots): + raise DomainValidationError("ACCOUNT_KIND_MISMATCH", "Сторона счёта не соответствует направлению расчёта") + selected = _expand(roots, children) - _expand(excluded, children) + return frozenset(account_id for account_id in selected if by_id[account_id].kind in _kinds(side)) + + +def calculate_result_table( + rows: Iterable[ConsolidatedRow], + member_companies: Sequence[IntercompanyMatrixCompanyOut], + accounts: Sequence[ChartOfAccount], +) -> IntercompanyResultTableOut: + """Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ.""" + by_id, children = _account_tree(accounts) + by_code = {account.code: account.id for account in accounts} + required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)} + missing = sorted(required_codes - by_code.keys()) + if missing: + raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}") + + selected = { + article.code: _selected_accounts( + (by_code[code] for code in article.account_codes), + (by_code[code] for code in article.excluded_codes), + article.side, + by_id, + children, + ) + for article in RESULT_ARTICLES + } + companies = tuple({company.id: company for company in member_companies}.values()) + member_ids = {company.id for company in companies} + amounts: dict[str, dict[uuid.UUID, Decimal]] = { + article.code: defaultdict(lambda: ZERO) for article in RESULT_ARTICLES + } + for row in rows: + if ( + row.legal_entity_id not in member_ids + or row.counterparty_id not in member_ids + or row.legal_entity_id == row.counterparty_id + or row.account_id is None + ): + continue + for article in RESULT_ARTICLES: + if row.account_id in selected[article.code]: + amounts[article.code][row.legal_entity_id] += _amount(row, "closing", article.side) + + articles: list[IntercompanyResultArticleOut] = [] + for article in RESULT_ARTICLES: + company_amounts = [ + IntercompanyResultAmountOut(company_id=company.id, amount=amounts[article.code].get(company.id, ZERO)) + for company in companies + ] + articles.append( + IntercompanyResultArticleOut( + code=article.code, + name=article.name, + amounts=company_amounts, + group_total=sum((item.amount for item in company_amounts), ZERO), + ) + ) + return IntercompanyResultTableOut(companies=list(companies), articles=articles) + + +class IntercompanyMatrixService: + """Получает свод и периметр версии, затем вызывает чистый расчёт матрицы.""" + + def __init__(self, session: AsyncSession, scope: VisibilityScope, service_osv: ConsolidatedOsvService) -> None: + self._session = session + self._scope = scope + self._osv_service = service_osv + + async def build( + self, + version_id: uuid.UUID, + selection: MatrixSelection, + ) -> IntercompanyMatrixOut: + """Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом.""" + consolidated, _, _, members = await self._osv_service.build_with_members(version_id) + return self._calculate(consolidated.rows, members, selection) + + def _calculate( + self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection + ) -> IntercompanyMatrixOut: + """Общий вызов расчёта с участниками из уже построенного свода.""" + companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()] + return calculate_matrix(rows, companies, selection) + + async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut: + """Статичная таблица по пяти статьям""" + consolidated, _, _, members = await self._osv_service.build_with_members(version_id) + accounts = await directory_repo.list_accounts(self._session) + companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()] + return calculate_result_table(consolidated.rows, companies, accounts) + + async def build_accounts( + self, + version_id: uuid.UUID, + *, + mode: Literal["ALL", "MANUAL"], + debit_account_ids: Sequence[uuid.UUID] = (), + credit_account_ids: Sequence[uuid.UUID] = (), + ) -> IntercompanyMatrixOut: + """ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков.""" + matrix, _, _, _ = await self._build_accounts( + version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids + ) + return matrix + + async def build_export( + self, + version_id: uuid.UUID, + *, + mode: Literal["ALL", "MANUAL"], + debit_account_ids: Sequence[uuid.UUID] = (), + credit_account_ids: Sequence[uuid.UUID] = (), + ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: + """Матрица и реквизиты книги из одного расчёта свода.""" + matrix, version, consolidation, accounts = await self._build_accounts( + version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids + ) + group_name = ( + await self._session.execute( + select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) + ) + ).scalar_one() + by_id = {account.id: account.code for account in accounts} + info = MatrixExportInfo( + group_name=group_name, + year=consolidation.year, + quarter=consolidation.quarter, + version_no=version.version_no, + mode=mode, + debit_account_codes=tuple(by_id[account_id] for account_id in debit_account_ids), + credit_account_codes=tuple(by_id[account_id] for account_id in credit_account_ids), + ) + return matrix, info + + async def _build_accounts( + self, + version_id: uuid.UUID, + *, + mode: Literal["ALL", "MANUAL"], + debit_account_ids: Sequence[uuid.UUID], + credit_account_ids: Sequence[uuid.UUID], + ) -> tuple[IntercompanyMatrixOut, ConsolidationVersion, Consolidation, list[ChartOfAccount]]: + consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) + accounts = await directory_repo.list_accounts(self._session) + by_id, children = _account_tree(accounts) + if mode == "ALL": + if debit_account_ids or credit_account_ids: + raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") + debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS) + credit = frozenset(account.id for account in accounts if account.kind in CREDIT_KINDS) + elif mode == "MANUAL": + if not debit_account_ids or not credit_account_ids: + raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") + debit = _selected_accounts(debit_account_ids, (), "D", by_id, children) + credit = _selected_accounts(credit_account_ids, (), "K", by_id, children) + else: + raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") + selection = MatrixSelection(debit, credit, "closing") + matrix = self._calculate(consolidated.rows, members, selection) + return matrix, version, consolidation, accounts diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py new file mode 100644 index 0000000..081be3f --- /dev/null +++ b/api/src/services/intercompany_matrix_export.py @@ -0,0 +1,117 @@ +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE +from openpyxl.comments import Comment +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut +from src.services.intercompany_matrix import MatrixExportInfo +from src.services.osv_export import MONEY_FORMAT + +METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %") +TABLE_HEADER_ROW = 10 +DATA_START_ROW = TABLE_HEADER_ROW + 1 +PERCENT_FORMAT = '0.00"%"' +THRESHOLD_FILL = PatternFill("solid", fgColor="FCE8E6") +THRESHOLD_FONT = Font(color="9C0006", bold=True) +HEADER_FILL = PatternFill("solid", fgColor="E7EDF5") +DIAGONAL_FILL = PatternFill("solid", fgColor="F2F2F2") +OUTLINE_SIDE = Side(style="thin", color="A6A6A6") + + +def _text(sheet: Worksheet, row: int, column: int, value: str) -> None: + """Записать текст без XML-символов и без превращения ведущего `=` в формулу.""" + cell = sheet.cell(row, column, ILLEGAL_CHARACTERS_RE.sub("", value)) + cell.data_type = "s" + + +def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> None: + if count: + sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА") + + +def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: + _text(sheet, 1, 1, "Шахматка внутригрупповых операций") + details = ( + ("ГК", info.group_name), + ("Период", f"{info.year}-{info.quarter.value}"), + ("Версия", f"v{info.version_no}"), + ("Единицы", "руб.; расхождение — %"), + ("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"), + ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"), + ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"), + ) + for row, (label, value) in enumerate(details, start=2): + _text(sheet, row, 1, label) + _text(sheet, row, 2, value) + sheet.cell(row, 1).font = Font(bold=True) + + +def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: + for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): + sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT + if cell.variance_percent is not None: + sheet.cell(row + 3, column, cell.variance_percent).number_format = PERCENT_FORMAT + _skipped_comment(sheet, row, column, cell.skipped_osv_entry_count) + + +def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> bytes: + workbook = Workbook() + sheet = workbook.active + assert sheet is not None + sheet.title = "Шахматка" + _write_context(sheet, info) + _text(sheet, TABLE_HEADER_ROW, 1, "Дебет ↓ / Кредит →") + _text(sheet, TABLE_HEADER_ROW, 2, "Показатель") + sheet.column_dimensions["A"].width = 32 + sheet.column_dimensions["B"].width = 40 + + for column, company in enumerate(matrix.companies, start=3): + _text(sheet, TABLE_HEADER_ROW, column, company.name) + sheet.column_dimensions[get_column_letter(column)].width = 26 + sheet.row_dimensions[TABLE_HEADER_ROW].height = 36 + for header in sheet[TABLE_HEADER_ROW]: + header.font = Font(bold=True) + header.fill = HEADER_FILL + header.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + cells = {(cell.company_a_id, cell.company_b_id): cell for cell in matrix.cells} + for company_index, debit_company in enumerate(matrix.companies): + start_row = DATA_START_ROW + company_index * len(METRICS) + end_row = start_row + len(METRICS) - 1 + sheet.merge_cells(start_row=start_row, start_column=1, end_row=end_row, end_column=1) + _text(sheet, start_row, 1, debit_company.name) + sheet.cell(start_row, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + for offset, metric in enumerate(METRICS): + _text(sheet, start_row + offset, 2, metric) + sheet.cell(start_row + offset, 2).alignment = Alignment(horizontal="center", vertical="center") + + for column, credit_company in enumerate(matrix.companies, start=3): + cell = cells.get((debit_company.id, credit_company.id)) + for offset in range(len(METRICS)): + sheet.cell(start_row + offset, column).border = Border( + left=OUTLINE_SIDE, + right=OUTLINE_SIDE, + top=OUTLINE_SIDE if offset == 0 else Side(), + bottom=OUTLINE_SIDE if offset == len(METRICS) - 1 else Side(), + ) + if debit_company.id == credit_company.id: + for offset in range(len(METRICS)): + sheet.cell(start_row + offset, column).fill = DIAGONAL_FILL + continue + if cell is None: + continue + _write_values(sheet, start_row, column, cell) + if cell.exceeds_threshold: + for offset in range(len(METRICS)): + marked = sheet.cell(start_row + offset, column) + marked.fill = THRESHOLD_FILL + marked.font = THRESHOLD_FONT + + sheet.freeze_panes = f"C{DATA_START_ROW}" + buffer = BytesIO() + workbook.save(buffer) + return buffer.getvalue() diff --git a/api/tests/test_api_accounts.py b/api/tests/test_api_accounts.py new file mode 100644 index 0000000..54c8497 --- /dev/null +++ b/api/tests/test_api_accounts.py @@ -0,0 +1,52 @@ +"""Плоский справочник счетов: все уровни и порядок кодов.""" + +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from src.api.endpoints.directories.accounts import list_accounts +from src.models.enums import AccountKind, AccountRowType +from src.repositories.directories import list_accounts as query_accounts + + +async def test_accounts_are_flat_and_naturally_sorted() -> None: + root_id, sub_id = uuid.uuid4(), uuid.uuid4() + rows = [ + SimpleNamespace( + id=sub_id, + code="60.10", + parent_id=root_id, + name="Авансы", + kind=AccountKind.A, + row_type=AccountRowType.subaccount, + is_off_balance=False, + ), + SimpleNamespace( + id=root_id, + code="60", + parent_id=None, + name="Расчёты", + kind=AccountKind.AP, + row_type=AccountRowType.account, + is_off_balance=False, + ), + SimpleNamespace( + id=uuid.uuid4(), + code="60.2", + parent_id=root_id, + name="Поставщики", + kind=AccountKind.P, + row_type=AccountRowType.subaccount, + is_off_balance=False, + ), + ] + session = SimpleNamespace(scalars=AsyncMock(return_value=SimpleNamespace(all=lambda: rows))) + + assert [item.code for item in await query_accounts(session)] == ["60", "60.2", "60.10"] + response = await list_accounts(session) + assert [item.code for item in response] == ["60", "60.2", "60.10"] + assert response[0].parent_id is None + assert response[2].parent_id == root_id + assert response[2].id == sub_id + assert [item.model_dump(mode="json")["is_debit"] for item in response] == [True, False, True] + assert [item.model_dump(mode="json")["is_credit"] for item in response] == [True, True, False] diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py new file mode 100644 index 0000000..0017786 --- /dev/null +++ b/api/tests/test_api_intercompany_matrix.py @@ -0,0 +1,288 @@ +"""Критерии шахматки через HTTP, реальные сервисы и PostgreSQL.""" + +import uuid +from dataclasses import dataclass +from decimal import Decimal +from io import BytesIO +from urllib.parse import unquote + +import pytest +from httpx import AsyncClient, Response +from openpyxl import load_workbook +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.accounting import ChartOfAccount +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter +from src.models.osv import OsvEntry +from src.repositories import consolidations as consolidation_repo +from src.repositories import directories as directory_repo +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity + +pytestmark = pytest.mark.integration +OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 +KA_RF = "ka_rf@armka.local" +ROUTES = ("all", "manual", "result-table", "all/export", "manual/export") + + +@dataclass +class World: + version: ConsolidationVersion + members: list[LegalEntity] + accounts: dict[str, ChartOfAccount] + + @property + def selection(self) -> dict[str, list[str]]: + return { + "debit_account_ids": [str(self.accounts["62.01"].id)], + "credit_account_ids": [str(self.accounts["60.01"].id)], + } + + +async def _world(session: AsyncSession, *, members: int = 2) -> World: + office = ( + await session.scalars(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) + ).one() + user = (await session.scalars(select(AppUser).where(AppUser.email == KA_RF))).one() + group = await make_company_group(session, office=office) + entities = [await make_legal_entity(session, name=f"Компания {n}", group=group) for n in range(members)] + _, version = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=user + ) + accounts = {account.code: account for account in (await session.scalars(select(ChartOfAccount))).all()} + return World(version, entities, accounts) + + +async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response: + url = f"/consolidations/versions/{version_id}/intercompany-matrix/{suffix}" + return await client.post(url, json=selection) if suffix.startswith("manual") else await client.get(url) + + +async def _source( + session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None +) -> uuid.UUID: + owner, counterparty = reversed(world.members) if credit else world.members + code = account or ("60.01" if credit else "62.01") + document = await make_confirmed_osv( + session, + entity=owner, + account=code, + year=2024, + quarter=PeriodQuarter.Q1, + is_cumulative=False, + rows=[ + { + "name": counterparty.name, + "counterparty_id": counterparty.id, + "counterparty_match_kind": CounterpartyMatchKind.INN, + "account_id": world.accounts[code].id, + "closing_credit" if credit else "closing_debit": Decimal(amount), + } + ], + ) + session.add(ConsolidationDocument(consolidation_version_id=world.version.id, document_id=document.id)) + await session.flush() + return document.id + + +@pytest.mark.parametrize("suffix", ["all", "manual"]) +async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: + world = await _world(seeded, members=0) + response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) + assert response.status_code == OK, response.text + assert response.json() == {"companies": [], "cells": []} + + +@pytest.mark.parametrize("suffix", ROUTES) +async def test_matrix_reuses_members_and_reads_accounts_through_repository( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch, suffix: str +) -> None: + world = await _world(seeded) + calls = {"members": 0, "accounts": 0} + original_members = consolidation_repo.members + original_accounts = directory_repo.list_accounts + + async def counted_members(*args: object, **kwargs: object): + calls["members"] += 1 + return await original_members(*args, **kwargs) + + async def counted_accounts(*args: object, **kwargs: object): + calls["accounts"] += 1 + return await original_accounts(*args, **kwargs) + + monkeypatch.setattr(consolidation_repo, "members", counted_members) + monkeypatch.setattr(directory_repo, "list_accounts", counted_accounts) + response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) + assert response.status_code == OK, response.text + assert calls == {"members": 1, "accounts": 1} + + +async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + await _source(seeded, world, credit=False, amount="999", account="58.03") + response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + assert response.status_code == OK, response.text + assert len(response.json()["cells"]) == len(world.members) + for cell in response.json()["cells"]: + assert {"osv_entry_ids_debit", "osv_entry_ids_credit", "exceeds_threshold"} <= cell.keys() + assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell) + assert cell["amount_a"] == cell["amount_b"] == cell["variance_amount"] == "0.00" + assert cell["variance_percent"] is None + assert cell["one_sided"] is False + assert cell["osv_entry_ids_debit"] == cell["osv_entry_ids_credit"] == [] + assert cell["skipped_osv_entry_count"] == 0 + + +async def test_unmapped_account_source_is_visible_in_cell_count( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded) + document_id = await _source(seeded, world, credit=False, amount="100") + entry = (await seeded.scalars(select(OsvEntry).where(OsvEntry.document_id == document_id))).one() + entry.account_id = None + await seeded.flush() + await _source(seeded, world, credit=True, amount="80") + response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + assert response.status_code == OK, response.text + cells = response.json()["cells"] + assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1] + cell = next(c for c in cells if c["company_a_id"] == str(world.members[0].id)) + assert cell["amount_a"] == "0.00" + assert cell["amount_b"] == "80.00" + assert cell["osv_entry_ids_debit"] == [] + assert len(cell["osv_entry_ids_credit"]) == 1 + + +@pytest.mark.parametrize("credit", [False, True]) +@pytest.mark.parametrize("amount", ["0", "100.25"]) +async def test_pair_without_counterpart( + auth_client: AuthClientFactory, seeded: AsyncSession, credit: bool, amount: str +) -> None: + world = await _world(seeded) + await _source(seeded, world, credit=credit, amount=amount) + response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + assert response.status_code == OK, response.text + cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) + assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount)) + assert Decimal(cell["amount_b"]) == (Decimal(amount) if credit else Decimal(0)) + assert cell["one_sided"] is True + assert cell["variance_percent"] is None + assert cell["exceeds_threshold"] is False + assert bool(cell["osv_entry_ids_credit"]) is credit + assert bool(cell["osv_entry_ids_debit"]) is not credit + + +@pytest.mark.parametrize("suffix", ROUTES) +async def test_visibility_and_permission(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: + world = await _world(seeded) + foreign = await auth_client("client_manager@armka.local") + hidden = await _request(foreign, world.version.id, suffix, world.selection) + unknown = await _request(foreign, uuid.uuid4(), suffix, world.selection) + assert hidden.status_code == unknown.status_code == NOT_FOUND + assert hidden.json() == unknown.json() + forbidden = await _request(await auth_client("admin@armka.local"), world.version.id, suffix, world.selection) + assert forbidden.status_code == FORBIDDEN + assert "CONSOLIDATION_VIEW" in forbidden.json()["detail"] + + +@pytest.mark.parametrize(("debit", "credit", "percent"), [("8890", "8000", "11.13"), ("105001", "100000", "5.00")]) +async def test_rounded_percent_in_api( + auth_client: AuthClientFactory, seeded: AsyncSession, debit: str, credit: str, percent: str +) -> None: + world = await _world(seeded) + await _source(seeded, world, credit=False, amount=debit) + await _source(seeded, world, credit=True, amount=credit) + response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + assert response.status_code == OK, response.text + cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) + assert cell["variance_percent"] == percent + assert cell["exceeds_threshold"] is True + assert cell["one_sided"] is False + + +@pytest.mark.parametrize("suffix", ["manual", "manual/export"]) +@pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"]) +@pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"]) +async def test_invalid_account_selection( + auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str, side: str, code: str +) -> None: + world = await _world(seeded) + selection = world.selection + wrong_kind = world.accounts["80" if side == "debit_account_ids" else "01"] + selection[side] = [str(uuid.uuid4() if code == "ACCOUNT_NOT_FOUND" else wrong_kind.id)] + response = await _request(await auth_client(KA_RF), world.version.id, suffix, selection) + assert response.status_code == UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == code + + +async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + # Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков. + world.accounts["58.03"].code = "58.03-missing" + await seeded.flush() + response = await _request(await auth_client(KA_RF), world.version.id, "result-table", world.selection) + assert response.status_code == UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING" + assert "58.03" in response.json()["detail"]["message"] + + +@pytest.mark.parametrize("mode", ["all", "manual"]) +async def test_export_filename_header_and_percentage( + auth_client: AuthClientFactory, seeded: AsyncSession, mode: str +) -> None: + world = await _world(seeded) + await _source(seeded, world, credit=False, amount="100") + await _source(seeded, world, credit=True, amount="90") + group_name = ( + await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id)) + ).one() + response = await _request(await auth_client(KA_RF), world.version.id, f"{mode}/export", world.selection) + assert response.status_code == OK, response.text + disposition = response.headers["content-disposition"] + assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == ( + f"intercompany-matrix_{group_name}_2024-Q1_v1.xlsx" + ) + workbook = load_workbook(BytesIO(response.content)) + sheet = workbook["Шахматка"] + assert [sheet[f"B{row}"].value for row in range(2, 9)] == [ + group_name, + "2024-Q1", + "v1", + "руб.; расхождение — %", + "Выбранные счета" if mode == "manual" else "Все счета", + "62.01" if mode == "manual" else "Все счета", + "60.01" if mode == "manual" else "Все счета", + ] + assert sheet["B14"].value == "Расхождение, %" + expected_percent = Decimal("11.11") + assert Decimal(str(sheet["D14"].value)) == expected_percent + assert sheet["D14"].number_format == '0.00"%"' + + +@pytest.mark.parametrize("mode", ["all", "manual"]) +async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: AsyncSession, mode: str) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT) + for _ in range(2): + response = await _request(client, world.version.id, f"{mode}/export", world.selection) + assert response.status_code == OK, response.text + events = (await seeded.scalars(query)).all() + assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку + assert events[0].id != events[1].id + for event in events: + assert event.section == AuditSection.EXPORT + assert event.object_type == "consolidation_version" + assert event.object_id == event.consolidation_version_id == world.version.id + assert event.user_id is not None and event.before_value is None + assert event.after_value == { + "report": "intercompany_matrix", + "mode": mode.upper(), + "cells": len(world.members), + **(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}), + } diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py new file mode 100644 index 0000000..971a135 --- /dev/null +++ b/api/tests/test_intercompany_matrix.py @@ -0,0 +1,223 @@ +"""Выбор счетов и расчёт шахматки без обращения к БД.""" + +import uuid +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from src.api.endpoints.consolidations.intercompany_matrix import router +from src.models.enums import AccountKind +from src.schemas.consolidations import ( + IntercompanyMatrixCellOut, + IntercompanyMatrixCompanyOut, + IntercompanyMatrixOut, + IntercompanyResultTableOut, +) +from src.services import intercompany_matrix as matrix + + +def _account(kind: AccountKind, parent_id: uuid.UUID | None = None) -> SimpleNamespace: + return SimpleNamespace(id=uuid.uuid4(), kind=kind, parent_id=parent_id) + + +def _row( + owner: uuid.UUID, + counterparty: uuid.UUID, + account_id: uuid.UUID | None, + *, + debit: str = "0", + credit: str = "0", +) -> SimpleNamespace: + return SimpleNamespace( + legal_entity_id=owner, + counterparty_id=counterparty, + account_id=account_id, + closing_debit=Decimal(debit), + closing_credit=Decimal(credit), + turnover_debit=Decimal("0"), + turnover_credit=Decimal("0"), + entry_ids=(), + ) + + +def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: + paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} + assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths + + debit_parent = _account(AccountKind.A) + debit_child = _account(AccountKind.A, debit_parent.id) + credit_parent = _account(AccountKind.P) + credit_child = _account(AccountKind.P, credit_parent.id) + by_id, children = matrix._account_tree([debit_parent, debit_child, credit_parent, credit_child]) + debit = matrix._selected_accounts([debit_parent.id], [], "D", by_id, children) + credit = matrix._selected_accounts([credit_parent.id], [], "K", by_id, children) + assert debit == {debit_parent.id, debit_child.id} + assert credit == {credit_parent.id, credit_child.id} + + company_a, company_b = uuid.uuid4(), uuid.uuid4() + result = matrix.calculate_matrix( + [ + _row(company_a, company_b, debit_child.id, debit="100"), + _row(company_b, company_a, credit_child.id, credit="90"), + ], + [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ], + matrix.MatrixSelection(debit, credit, "closing"), + ) + assert [(company.id, company.name) for company in result.companies] == [ + (company_a, "Компания А"), + (company_b, "Компания Б"), + ] + assert result.cells[0].amount_a == Decimal("100") + assert result.cells[0].amount_b == Decimal("90") + assert result.cells[0].variance_percent == Decimal("11.11") + assert isinstance(result, IntercompanyMatrixOut) + response = result + assert response.model_dump(mode="json")["companies"] == [ + {"id": str(company_a), "name": "Компания А"}, + {"id": str(company_b), "name": "Компания Б"}, + ] + + +def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None: + company_a, company_b = uuid.uuid4(), uuid.uuid4() + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + companies = [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ] + selection = matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") + debit = _row(company_a, company_b, debit_account) + credit = _row(company_b, company_a, credit_account) + debit.entry_ids = (uuid.uuid4(),) + credit.entry_ids = (uuid.uuid4(),) + + for rows, expected in (([], False), ([debit], True), ([credit], True), ([debit, credit], False)): + cell = matrix.calculate_matrix(rows, companies, selection).cells[0] + assert cell.one_sided is expected + assert cell.model_dump(mode="json")["one_sided"] is expected + + +def test_unmapped_account_entries_are_counted_for_both_directions_of_pair() -> None: + company_a, company_b, outsider = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + account_id = uuid.uuid4() + missing_a = _row(company_a, company_b, None, debit="100") + missing_a.entry_ids = (uuid.uuid4(), uuid.uuid4()) + missing_b = _row(company_b, company_a, None, credit="90") + missing_b.entry_ids = (uuid.uuid4(),) + outside = _row(company_a, outsider, None, debit="500") + outside.entry_ids = (uuid.uuid4(),) + matched = _row(company_a, company_b, account_id, debit="20") + matched.entry_ids = (uuid.uuid4(),) + companies = [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ] + result = matrix.calculate_matrix( + [missing_a, missing_b, outside, matched], + companies, + matrix.MatrixSelection(frozenset({account_id}), frozenset({account_id}), "closing"), + ) + expected_skipped = len(missing_a.entry_ids) + len(missing_b.entry_ids) + assert [cell.skipped_osv_entry_count for cell in result.cells] == [expected_skipped, expected_skipped] + assert result.cells[0].model_dump(mode="json")["skipped_osv_entry_count"] == expected_skipped + assert result.cells[0].amount_a == Decimal("20") + assert result.cells[0].amount_b == Decimal("0") + + +@pytest.mark.parametrize( + ("debit", "expected", "exceeds"), + [ + ("111.125", "11.13", True), + ("111.124", "11.12", True), + ("105", "5.00", False), + ("105.004", "5.00", True), + ("104.999", "5.00", False), + ("100", "0.00", False), + ], +) +def test_percent_rounding_preserves_threshold(debit: str, expected: str, exceeds: bool) -> None: + company_a, company_b, account = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + result = matrix.calculate_matrix( + [_row(company_a, company_b, account, debit=debit), _row(company_b, company_a, account, credit="100")], + [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ], + matrix.MatrixSelection(frozenset({account}), frozenset({account}), "closing"), + ) + assert result.cells[0].variance_percent == Decimal(expected) + assert result.cells[0].model_dump(mode="json")["variance_percent"] == expected + assert result.cells[0].exceeds_threshold is exceeds + + +@pytest.mark.parametrize( + ("value", "expected"), + [("0", "0.00"), ("1.005", "1.01"), ("-1.005", "-1.01"), ("3.873533480903112697872235289", "3.87")], +) +def test_cell_numbers_have_two_decimals(value: str, expected: str) -> None: + fields = ("amount_a", "amount_b", "variance_amount", "variance_percent") + cell = IntercompanyMatrixCellOut( + company_a_id=uuid.uuid4(), + company_b_id=uuid.uuid4(), + **dict.fromkeys(fields, value), + exceeds_threshold=False, + one_sided=False, + skipped_osv_entry_count=0, + osv_entry_ids_debit=[], + osv_entry_ids_credit=[], + ) + assert {field: cell.model_dump(mode="json")[field] for field in fields} == dict.fromkeys(fields, expected) + + +def test_result_table_uses_fixed_articles_and_group_totals() -> None: + company_a, company_b, external = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + required_codes = { + code for article in matrix.RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes) + } + kinds = { + code: AccountKind.A if article.side == "D" else AccountKind.P + for article in matrix.RESULT_ARTICLES + for code in article.account_codes + } + kinds.update({"76": AccountKind.AP, "76.07.1": AccountKind.P, "76.07.5": AccountKind.A, "76.07.9": AccountKind.A}) + ids = {code: uuid.uuid4() for code in required_codes} + parents = {"67.03": "67", "66.03": "66", "66.04": "66", "76.07.1": "76", "76.07.5": "76", "76.07.9": "76"} + accounts = [ + SimpleNamespace(id=ids[code], code=code, kind=kinds[code], parent_id=ids.get(parents.get(code))) + for code in required_codes + ] + rows = [ + _row(company_a, company_b, ids["60.02"], debit="100"), + _row(company_a, company_b, ids["76.07.5"], debit="50"), + _row(company_a, company_b, ids["76.07.1"], credit="20"), + _row(company_a, company_b, ids["58.01"], debit="30"), + _row(company_b, company_a, ids["60.01"], credit="70"), + _row(company_b, company_a, ids["67.03"], credit="40"), + _row(company_a, company_b, ids["66.04"], credit="10"), + _row(company_a, external, ids["60.02"], debit="999"), + ] + result = matrix.calculate_result_table( + rows, + [ + IntercompanyMatrixCompanyOut(id=company_a, name="Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ], + accounts, + ) + articles = {article.code: article for article in result.articles} + assert list(articles) == [article.code for article in matrix.RESULT_ARTICLES] + assert [item.amount for item in articles["receivables"].amounts] == [Decimal("100"), Decimal("0")] + assert articles["receivables"].group_total == Decimal("100") + assert articles["payables"].group_total == Decimal("70") + assert articles["financial_investments"].group_total == Decimal("30") + assert articles["long_term_loans"].group_total == Decimal("40") + assert articles["short_term_loans"].group_total == Decimal("10") + assert isinstance(result, IntercompanyResultTableOut) + response = result + assert response.articles[0].amounts[0].company_id == company_a diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py new file mode 100644 index 0000000..20fcc97 --- /dev/null +++ b/api/tests/test_intercompany_matrix_export.py @@ -0,0 +1,231 @@ +"""XLSX-выгрузка шахматки из готового результата расчёта.""" + +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import AsyncMock +from urllib.parse import unquote +from uuid import UUID + +import pytest +from openpyxl import load_workbook + +from src.api.endpoints.consolidations.intercompany_matrix import ( + export_intercompany_matrix_all, + export_intercompany_matrix_manual, + router, +) +from src.models.enums import AuditAction, AuditSection, PeriodQuarter +from src.schemas.consolidations import ( + IntercompanyMatrixCellOut, + IntercompanyMatrixCompanyOut, + IntercompanyMatrixManualIn, + IntercompanyMatrixOut, +) +from src.services.intercompany_matrix import MatrixExportInfo +from src.services.intercompany_matrix_export import DIAGONAL_FILL, METRICS, THRESHOLD_FILL, build_matrix_workbook + + +def _info(*, mode: str = "MANUAL") -> MatrixExportInfo: + return MatrixExportInfo( + group_name='ГК "Кавычки" / Бисквит', + year=2024, + quarter=PeriodQuarter.Q3, + version_no=2, + mode=mode, + debit_account_codes=("62.01",) if mode == "MANUAL" else (), + credit_account_codes=("60.01",) if mode == "MANUAL" else (), + ) + + +def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold() -> None: + company_a = UUID("11111111-1111-1111-1111-111111111111") + company_b = UUID("22222222-2222-2222-2222-222222222222") + entry_id = UUID("33333333-3333-3333-3333-333333333333") + matrix = IntercompanyMatrixOut( + companies=[ + IntercompanyMatrixCompanyOut(id=company_a, name="=Компания А"), + IntercompanyMatrixCompanyOut(id=company_b, name="Компания Б"), + ], + cells=[ + IntercompanyMatrixCellOut( + company_a_id=company_a, + company_b_id=company_b, + amount_a="100.25", + amount_b="90.00", + variance_amount="10.25", + variance_percent="11.388888", + exceeds_threshold=True, + one_sided=True, + skipped_osv_entry_count=2, + osv_entry_ids_debit=[entry_id], + osv_entry_ids_credit=[], + ), + IntercompanyMatrixCellOut( + company_a_id=company_b, + company_b_id=company_a, + amount_a="20", + amount_b="20", + variance_amount="0", + variance_percent="0", + exceeds_threshold=False, + one_sided=False, + skipped_osv_entry_count=0, + osv_entry_ids_debit=[], + osv_entry_ids_credit=[], + ), + ], + ) + + workbook = load_workbook(BytesIO(build_matrix_workbook(matrix, _info()))) + sheet = workbook.active + assert sheet is not None + assert sheet.title == "Шахматка" + assert [cell.value for cell in sheet[10]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"] + assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[10]) + assert sheet["C10"].data_type == "s" + assert sheet["A1"].value == "Шахматка внутригрупповых операций" + assert [(sheet.cell(row, 1).value, sheet.cell(row, 2).value) for row in range(2, 9)] == [ + ("ГК", 'ГК "Кавычки" / Бисквит'), + ("Период", "2024-Q3"), + ("Версия", "v2"), + ("Единицы", "руб.; расхождение — %"), + ("Режим", "Выбранные счета"), + ("Счета дебета", "62.01"), + ("Счета кредита", "60.01"), + ] + assert sheet["A11"].value == "=Компания А" + assert sheet["A11"].data_type == "s" + assert sheet["A11"].alignment.horizontal == "center" + assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18"} + assert [sheet.cell(row, 2).value for row in range(11, 15)] == list(METRICS) + assert [sheet.cell(row, 2).value for row in range(15, 19)] == list(METRICS) + assert all(sheet.cell(row, 2).alignment.horizontal == "center" for row in range(11, 19)) + assert sheet["A15"].value == "Компания Б" + assert [sheet.cell(row, 3).value for row in range(11, 15)] == [None] * 4 + assert all(sheet.cell(row, 3).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in range(11, 15)) + assert all(sheet.cell(row, 4).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in range(15, 19)) + assert tuple(sheet.cell(row, 4).value for row in range(11, 15)) == (10.25, 100.25, 90, 11.39) + assert sheet["D14"].number_format == '0.00"%"' + assert all(sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb for row in range(11, 15)) + assert sheet["D11"].comment is not None + assert "2" in sheet["D11"].comment.text + assert all( + sheet.cell(row, column).comment is None + for row in range(11, 19) + for column in (3, 4) + if (row, column) != (11, 4) + ) + assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0] + assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19)) + assert sheet.freeze_panes == "C11" + for column, first_row in ((3, 11), (4, 11), (3, 15), (4, 15)): + top, middle, bottom = (sheet.cell(first_row + offset, column) for offset in (0, 1, 3)) + assert top.border.top.style == "thin" + assert top.border.bottom.style is None + assert middle.border.top.style is None + assert middle.border.bottom.style is None + assert bottom.border.bottom.style == "thin" + assert all( + sheet.cell(first_row + offset, column).border.left.style == "thin" + and sheet.cell(first_row + offset, column).border.right.style == "thin" + for offset in range(4) + ) + + +async def test_both_export_routes_use_matrix_calculation() -> None: + version_id = UUID("11111111-1111-1111-1111-111111111111") + account_id = UUID("22222222-2222-2222-2222-222222222222") + company_a = IntercompanyMatrixCompanyOut(id=version_id, name="Компания А") + company_b = IntercompanyMatrixCompanyOut(id=account_id, name="Компания Б") + matrix = IntercompanyMatrixOut( + companies=[company_a, company_b], + cells=[ + IntercompanyMatrixCellOut( + company_a_id=company_a.id, + company_b_id=company_b.id, + amount_a="0", + amount_b="0", + variance_amount="0", + variance_percent=None, + exceeds_threshold=False, + one_sided=False, + skipped_osv_entry_count=0, + osv_entry_ids_debit=[], + osv_entry_ids_credit=[], + ) + ], + ) + calls = [] + + async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: + calls.append((args, kwargs)) + return matrix, _info(mode=kwargs["mode"]) + + service = SimpleNamespace(build_export=build_export) + audit = SimpleNamespace(record=AsyncMock()) + selection = IntercompanyMatrixManualIn(debit_account_ids=[account_id], credit_account_ids=[account_id]) + + all_response = await export_intercompany_matrix_all(version_id, service, audit) + manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit) + + assert calls == [ + ((version_id,), {"mode": "ALL"}), + ((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}), + ] + for response in (all_response, manual_response): + assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment; filename=\"intercompany-matrix.xlsx\"; filename*=UTF-8''") + assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == _info().filename + book = load_workbook(BytesIO(response.body)) + assert book.active["B6"].value in {"Все счета", "Выбранные счета"} + assert [call.kwargs for call in audit.record.await_args_list] == [ + { + "action": AuditAction.EXPORT, + "section": AuditSection.EXPORT, + "object_type": "consolidation_version", + "object_id": version_id, + "consolidation_version_id": version_id, + "after_value": { + "report": "intercompany_matrix", + "mode": "ALL", + "debit_account_ids": [], + "credit_account_ids": [], + "cells": 1, + }, + }, + { + "action": AuditAction.EXPORT, + "section": AuditSection.EXPORT, + "object_type": "consolidation_version", + "object_id": version_id, + "consolidation_version_id": version_id, + "after_value": { + "report": "intercompany_matrix", + "mode": "MANUAL", + "debit_account_ids": [str(account_id)], + "credit_account_ids": [str(account_id)], + "cells": 1, + }, + }, + ] + + paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} + assert ("/consolidations/versions/{version_id}/intercompany-matrix/all/export", ("GET",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual/export", ("POST",)) in paths + + +async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None: + version_id = UUID("11111111-1111-1111-1111-111111111111") + service = SimpleNamespace( + build_export=AsyncMock(return_value=(IntercompanyMatrixOut(companies=[], cells=[]), _info())) + ) + audit = SimpleNamespace(record=AsyncMock()) + + def fail_workbook(_matrix: IntercompanyMatrixOut, _info: MatrixExportInfo) -> bytes: + raise ValueError("xlsx failed") + + monkeypatch.setattr("src.api.endpoints.consolidations.intercompany_matrix.build_matrix_workbook", fail_workbook) + with pytest.raises(ValueError, match="xlsx failed"): + await export_intercompany_matrix_all(version_id, service, audit) + audit.record.assert_not_awaited() diff --git a/api/tests/test_intercompany_matrix_golden.py b/api/tests/test_intercompany_matrix_golden.py new file mode 100644 index 0000000..5e7d819 --- /dev/null +++ b/api/tests/test_intercompany_matrix_golden.py @@ -0,0 +1,100 @@ +"""Точные пары «Русского бисквита» на 30.09.2024 из ОСВ и ручного эталона. + +Решение владельца 08.10.2026: общий итог элиминации с книгой не сравниваем — +аналитик снимает полную сумму каждой стороны, система использует минимум пары. +Здесь явно выбраны только точные встречные строки. Для 62.01 это производство +молочной булочки (строка 21); аренда (строка 17) в эту пару не входит. +Тест проверяет расчёт выбранных пар, а не автоматический подбор строк из всей ОСВ. +""" + +import json +import uuid +from decimal import ROUND_HALF_UP, Decimal +from pathlib import Path +from types import SimpleNamespace + +import pytest +from openpyxl import load_workbook + +from src.schemas.consolidations import IntercompanyMatrixCompanyOut +from src.services.intercompany_matrix import MatrixSelection, calculate_matrix + +pytestmark = pytest.mark.fixtures +GOLDEN = Path("golden/expected_osv_parse") +RB = uuid.UUID("11111111-1111-1111-1111-111111111111") +BAKERY = uuid.UUID("22222222-2222-2222-2222-222222222222") + + +def _source(fixtures_dir: Path, folder: str, account: str, number: int) -> dict: + matches = [ + row + for path in sorted((fixtures_dir / GOLDEN / folder / "2024-Q3").glob("*.json")) + for document in json.loads(path.read_text("utf-8"))["documents"] + if document["document_type"] == "OSV" and document["header"]["account_code_raw"] == account + for row in document["rows"] + if row["row_number"] == number and row["group_type"] == "COUNTERPARTY" and row["data_type"] == "BU" + ] + assert len(matches) == 1, (folder, account, number) + return matches[0] + + +@pytest.mark.parametrize( + ("debit_root", "debit_row", "credit_root", "credit_row", "amount", "golden_cells"), + [ + ("62", 21, "60", 409, "106311116.46", ("F51",)), + # F50 содержит 42 млн основного долга + 350 тыс. процентов; J29 — основной долг. + ("76", 20, "66", 28, "350000.00", ("F50", "J29")), + ], +) +def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - описание пары и ячеек эталона + fixtures_dir: Path, + *, + debit_root: str, + debit_row: int, + credit_root: str, + credit_row: int, + amount: str, + golden_cells: tuple[str, ...], +) -> None: + debit = _source(fixtures_dir, "schastl", debit_root, debit_row) + credit = _source(fixtures_dir, "rb", credit_root, credit_row) + assert debit["counterparty_inn"] == "3528052718" + assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"] + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + rows = [ + SimpleNamespace( + legal_entity_id=owner, + counterparty_id=counterparty, + account_id=account_id, + closing_debit=Decimal(source["closing_debit"] or "0"), + closing_credit=Decimal(source["closing_credit"] or "0"), + entry_ids=(uuid.uuid4(),), + ) + for owner, counterparty, account_id, source in ( + (BAKERY, RB, debit_account, debit), + (RB, BAKERY, credit_account, credit), + ) + ] + result = calculate_matrix( + rows, + [ + IntercompanyMatrixCompanyOut(id=BAKERY, name="Пекарня Счастливочная"), + IntercompanyMatrixCompanyOut(id=RB, name="Русский бисквит"), + ], + MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"), + ) + cell = result.cells[0] + assert cell.amount_a == cell.amount_b == Decimal(amount) + assert cell.variance_amount == cell.variance_percent == Decimal(0) + assert cell.one_sided is False and cell.exceeds_threshold is False + with (fixtures_dir / "golden/Консолидация_Группа РБ.xlsx").open("rb") as source: + workbook = load_workbook(source, read_only=True, data_only=True) + try: + sheet = workbook["01.10.2024"] + expected = Decimal(str(sheet[golden_cells[0]].value)) + for address in golden_cells[1:]: + expected -= Decimal(str(sheet[address].value)) + finally: + workbook.close() + # В книге тысячи рублей с округлением до целого; входные ОСВ содержат рубли и копейки. + assert (cell.amount_a / 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP) == expected diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 8bc79b4..a5b1566 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -20,6 +20,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/me": {"get"}, # directories-api: филиалы, ГК, ГСК/ГСЗ, ЮЛ/ИП f"{D}/regional-offices": {"get"}, + f"{D}/accounts": {"get"}, f"{D}/company-groups": {"get", "post"}, f"{D}/company-groups/{{group_id}}": {"get", "patch"}, f"{D}/company-groups/{{group_id}}/archive": {"post"}, @@ -72,6 +73,13 @@ 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}/elimination-rules": {"get"}, + "/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"}, + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"}, + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно diff --git a/openspec/changes/intercompany-matrix/.openspec.yaml b/openspec/changes/intercompany-matrix/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/intercompany-matrix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/intercompany-matrix/design.md b/openspec/changes/intercompany-matrix/design.md new file mode 100644 index 0000000..0be5e7f --- /dev/null +++ b/openspec/changes/intercompany-matrix/design.md @@ -0,0 +1,50 @@ +## Context + +Реализация — PR RSHB/arm-core#114 (AURORA-1537). Шахматка — чистая функция от сводной ОСВ версии, периметра и выбранных +счетов; в БД не хранится (инвариант «производные консолидации не хранятся», DECISION-5). + +## Decisions + +1. Матрица считается поверх `ConsolidatedOsvService.build_with_members` (бывший приватный `_build`): строки свода и + участники периметра берутся из того же расчёта, что и сводная ОСВ; в `SourceRow`/`ConsolidatedRow` добавлено + `account_id`. +2. Пара `(A, B)`: сторона 1 — дебет ОСВ A по контрагенту B, сторона 2 — кредит ОСВ B по контрагенту A. Расчёт + поддерживает показатели `closing`/`turnover`, но в режимах ALL и MANUAL всегда используется конечное сальдо + (`closing`). +3. Режим ALL берёт все счета вида `A`/`AP` (дебет) и `P`/`AP` (кредит); MANUAL раскрывает выбранные счета по иерархии + `chart_of_account` и фильтрует по виду. +4. Порог расхождения 5 % проверяется по точному значению до округления; суммы в JSON — десятичные строки с двумя + знаками (`ROUND_HALF_UP`). +5. Строки пары без `account_id` в суммы не входят и считаются в `skipped_osv_entry_count`. +6. Результирующая таблица — пять фиксированных статей (`RESULT_ARTICLES`) со списками кодов счетов и исключений; + отсутствие любого кода в плане счетов — 422 `RESULT_ACCOUNTS_MISSING`. +7. xlsx: имя файла `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`; каждая выгрузка пишет + собственное событие `audit_event(EXPORT)`. +8. Сверка с эталоном «Русский бисквит» — только по точным парам счетов и компаний; общая сумма элиминации эталона не + приравнивается к минимуму по паре (решение владельца 08.10.2026). + +## Known issues + +Требования ниже описывают текущее поведение; дефекты вынесены в задачи Jira и в этом change не исправляются. + +- AURORA-1651: `one_sided` считается по наличию исходных записей, а не по суммам и строкам-основаниям — пара «100 млн + против строки с нулевым сальдо» не подсвечивается, «0 против 0» подсвечивается ложно; подсветка таких пар в xlsx; + режим ALL захватывает забалансовые счета 001–012. +- AURORA-1652: `GET …/elimination-rules` пуст до задачи 1536, хотя требование описывает непустой ответ; формат сумм + результирующей таблицы `"0"` вместо `"0.00"` (расхождение с форматом матрицы); ссылки на §§ в docstring; ссылка README + на гайд фронта. +- AURORA-1653: переход «ячейка → сводная ОСВ» — фильтр по паре в `GET …/osv`; `osv_entry_ids_*` содержат ID нулевых + строк и не отсортированы. + +## Decisions made without discussion + +- 16.10.2026. Требования автора перенесены из `openspec/specs/*` в дельты `ADDED` дословно, без правки содержания. + Обоснование: владелец поручил оформить change процессно, а содержательные замечания ревью уже вынесены в задачи. + Цена ошибки: низкая — текст можно поправить в этом же change'е до архивации. +- 16.10.2026. Спека фиксирует текущее поведение, включая семантику `one_sided` и формат `"0"` результирующей таблицы; + исправление требований идёт вместе с кодом в AURORA-1651/1652/1653 (там же MODIFIED-дельты). Обоснование: иначе спека + разойдётся с кодом PR. Цена ошибки: до исправления спека описывает известные дефекты (отмечены в `## Known issues`). +- 16.10.2026. Все требования оформлены как `ADDED` в существующих capability, новая capability не заводится. + Обоснование: шахматка — отчёт по сводной ОСВ (consolidated-osv), API версий (consolidations-api), справочников и + аудита; отдельная capability дробила бы уже согласованную структуру. Цена ошибки: низкая, перенос между capability — + правка путей дельт. diff --git a/openspec/changes/intercompany-matrix/proposal.md b/openspec/changes/intercompany-matrix/proposal.md new file mode 100644 index 0000000..351169c --- /dev/null +++ b/openspec/changes/intercompany-matrix/proposal.md @@ -0,0 +1,32 @@ +## Why + +Отчёт «Шахматка ВГО» (ТЗ §6): аналитик сверяет внутригрупповые обороты пар компаний периметра по строкам сводной ОСВ +версии, видит расхождения сверх порога, получает результирующую таблицу по пяти статьям и выгрузку в xlsx. Jira +AURORA-1537, реализация — PR RSHB/arm-core#114 (автор — Потапова). Требования автор дописала прямо в +`openspec/specs/`; при подготовке к слиянию они оформлены как дельты этого change'а (решение владельца 16.10.2026). + +## What Changes + +- Расчёт шахматки без хранения в БД (`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`) по строкам + сводной ОСВ версии и её периметру; режимы ALL и MANUAL. +- Результирующая таблица ВГО из пяти фиксированных статей (`GET …/intercompany-matrix/result-table`). +- Выгрузка xlsx шахматки (`…/all/export`, `…/manual/export`) и аудит `EXPORT` каждой выгрузки. +- Правила элиминации версии (`GET …/elimination-rules`). +- Плоский список счетов для фильтров (`GET /api/v1/directories/accounts`). +- В сводную ОСВ добавлен `account_id` строки (внутреннее поле домена, в API свода не выходит). + +## Capabilities + +### New Capabilities +Нет. + +### Modified Capabilities +- `consolidated-osv`: шахматка по сводной ОСВ и её xlsx. +- `consolidations-api`: правила элиминации версии, результирующая таблица ВГО. +- `directories-api`: плоский список счетов. +- `audit-trail`: аудит экспорта шахматки. + +## Impact + +Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Код реализован и протестирован в PR #114; этот +change фиксирует требования к текущему поведению ветки, включая известные дефекты (см. `design.md`, `## Known issues`). diff --git a/openspec/changes/intercompany-matrix/specs/audit-trail/spec.md b/openspec/changes/intercompany-matrix/specs/audit-trail/spec.md new file mode 100644 index 0000000..5266ea8 --- /dev/null +++ b/openspec/changes/intercompany-matrix/specs/audit-trail/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Аудит экспорта шахматки +Каждый успешный `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` +SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, +`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, +`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, +credit_account_ids, cells}`. `mode` — `ALL` или `MANUAL`, массивы счетов — ID из запроса (в ALL пустые), +`cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`. + +#### Scenario: Две выгрузки +- **WHEN** пользователь дважды выгружает ту же шахматку +- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек diff --git a/openspec/changes/intercompany-matrix/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..734e365 --- /dev/null +++ b/openspec/changes/intercompany-matrix/specs/consolidated-osv/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Шахматка по сводной ОСВ версии +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и +`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual` +(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии +и её периметра. ALL включает все счета вида `A`/`AP` для дебета и `P`/`AP` для кредита. MANUAL принимает +обязательные непустые `debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета +по иерархии и оставляет подходящий вид. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — +422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL +отвечать одинаковым 404 `NOT_FOUND`. + +Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары +разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, +`exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`. +Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, +`amount_b` — сумма `closing_credit` строк B с контрагентом A по кредитовым. Расхождение — модуль разности; +процент — расхождение / меньшую сумму × 100 или null при нуле. `exceeds_threshold` SHALL быть true при точном +проценте > 5, до округления; суммы и процент в JSON SHALL быть десятичными строками с двумя знаками после +запятой (`ROUND_HALF_UP`). `one_sided` SHALL быть true при наличии исходных записей ОСВ только с одной стороны, +в том числе при нулевой сумме. `skipped_osv_entry_count` SHALL считать исходные строки ОСВ пары с +`account_id = null` на обеих сторонах; они не входят в суммы. Массивы `osv_entry_ids_debit/credit` SHALL +содержать ID исходных записей соответствующей стороны. + +#### Scenario: Пустой периметр и отсутствие ОСВ +- **WHEN** у версии нет членов периметра +- **THEN** `companies = []`, `cells = []`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами + +#### Scenario: Только одна сторона пары +- **WHEN** у A есть строка ОСВ с контрагентом B, а у B встречной строки нет +- **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст + +#### Scenario: Счёт не сопоставлен +- **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids` +- **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2 + +#### Scenario: Эталон «Русский бисквит» +- **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур +- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) + +### Requirement: XLSX шахматки +`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать +те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем +`intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, +выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары. + +#### Scenario: Экспорт выбранных счетов +- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 +- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования diff --git a/openspec/changes/intercompany-matrix/specs/consolidations-api/spec.md b/openspec/changes/intercompany-matrix/specs/consolidations-api/spec.md new file mode 100644 index 0000000..7ed286b --- /dev/null +++ b/openspec/changes/intercompany-matrix/specs/consolidations-api/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Правила элиминации версии +`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК) +SHALL возвращать все правила версии массивом. Каждое правило SHALL содержать `rule_no`, `version_no`, +`statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`; +каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов. +Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. + +#### Scenario: Правило с исключением +- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5 +- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded` + +### Requirement: Результирующая таблица ВГО +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра +по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`. +Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, +долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра +из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. +Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. + +#### Scenario: Компания без ОСВ +- **WHEN** в периметре есть компания без подходящих строк ОСВ +- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"` + +#### Scenario: Нет обязательного счёта +- **WHEN** в плане счетов отсутствует счёт 58.03 +- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03` diff --git a/openspec/changes/intercompany-matrix/specs/directories-api/spec.md b/openspec/changes/intercompany-matrix/specs/directories-api/spec.md new file mode 100644 index 0000000..d6997c0 --- /dev/null +++ b/openspec/changes/intercompany-matrix/specs/directories-api/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Плоский список счетов для фильтров шахматки +`GET /api/v1/directories/accounts` SHALL возвращать пользователю с `DIRECTORY_VIEW` все строки +`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка SHALL содержать `id`, `code`, +`parent_id`, `name`, `kind` (`A`/`P`/`AP`/null), `row_type`, `is_off_balance`, `is_debit`, `is_credit`; +корневые счета имеют `parent_id = null`. `is_debit = true` для `A` и `AP`, `is_credit = true` для `P` и `AP`; +при `kind = null` оба признака ложны. + +#### Scenario: Счета разных уровней +- **WHEN** в плане счетов есть счёт, субсчёт и аналитика +- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку + +#### Scenario: Нет права на справочник +- **WHEN** пользователь без `DIRECTORY_VIEW` запрашивает список счетов +- **THEN** ответ 403 diff --git a/openspec/changes/intercompany-matrix/tasks.md b/openspec/changes/intercompany-matrix/tasks.md new file mode 100644 index 0000000..36b2415 --- /dev/null +++ b/openspec/changes/intercompany-matrix/tasks.md @@ -0,0 +1,4 @@ +## 1. Реализация + +Реализация уже выполнена автором в PR RSHB/arm-core#114 (AURORA-1537); задач плана нет. Доработки по замечаниям ревью — +AURORA-1651, AURORA-1652, AURORA-1653.