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/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 5ba3fa8..2fd695a 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -1,16 +1,19 @@ """Расчёт шахматки по всем или выбранным счетам.""" import uuid -from typing import Any +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") @@ -27,16 +30,41 @@ EXPORT_RESPONSES = { } -async def _xlsx_response(matrix: IntercompanyMatrixOut) -> Response: - content = await run_in_threadpool(build_matrix_workbook, matrix) - filename = "intercompany-matrix.xlsx" +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(filename, ascii_fallback=filename)}, + 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, @@ -62,9 +90,10 @@ async def build_intercompany_matrix_all( async def export_intercompany_matrix_all( version_id: uuid.UUID, service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], ) -> Response: - matrix = await service.build_accounts(version_id, mode="ALL") - return await _xlsx_response(matrix) + matrix, info = await service.build_export(version_id, mode="ALL") + return await _export_response(version_id, matrix, info, audit) @router.post( @@ -99,14 +128,21 @@ async def export_intercompany_matrix_manual( version_id: uuid.UUID, selection: IntercompanyMatrixManualIn, service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], ) -> Response: - matrix = await service.build_accounts( + 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 _xlsx_response(matrix) + return await _export_response( + version_id, + matrix, + info, + audit, + selection=selection, + ) @router.get( diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 8e0e609..adde35c 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -8,7 +8,7 @@ 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 @@ -343,9 +343,20 @@ class IntercompanyMatrixCellOut(BaseModel): amount_b: Decimal variance_amount: Decimal variance_percent: Decimal | None - is_more_5_percent: bool - entry_osv_ids_debit: list[uuid.UUID] - entry_osv_ids_credit: list[uuid.UUID] + 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): diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 03b6d7d..4e6542b 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -138,6 +138,13 @@ class ConsolidatedOsvService: 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) @@ -167,7 +174,7 @@ 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).""" diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index c35d1cb..f14db8c 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -11,11 +11,13 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import DomainValidationError -from src.domain.consolidated_osv import ConsolidatedRow +from src.domain.consolidated_osv import ConsolidatedRow, Entity from src.domain.policy import VisibilityScope from src.models.accounting import ChartOfAccount -from src.models.enums import AccountKind -from src.repositories import consolidations as repo +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, @@ -73,6 +75,21 @@ class MatrixSelection: 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 @@ -99,13 +116,17 @@ def calculate_matrix( 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 or row.account_id is None: + 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") @@ -129,7 +150,9 @@ def calculate_matrix( variance_amount = abs(amount_a - amount_b) base = min(amount_a, amount_b) variance_percent = None if base == ZERO else variance_amount / base * 100 - is_more_5_percent = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT + 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, @@ -138,9 +161,12 @@ def calculate_matrix( amount_b=amount_b, variance_amount=variance_amount, variance_percent=variance_percent, - is_more_5_percent=is_more_5_percent, - entry_osv_ids_debit=item_a["entry_osv_ids"] if item_a is not None else (), - entry_osv_ids_credit=item_b["entry_osv_ids"] if item_b is not None else (), + 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) @@ -262,23 +288,21 @@ class IntercompanyMatrixService: selection: MatrixSelection, ) -> IntercompanyMatrixOut: """Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом.""" - consolidated, version, _ = await self._osv_service.build(version_id) - return await self._calculate(consolidated.rows, version.id, selection) + consolidated, _, _, members = await self._osv_service.build_with_members(version_id) + return self._calculate(consolidated.rows, members, selection) - async def _calculate( - self, rows: Iterable[ConsolidatedRow], version_id: uuid.UUID, selection: MatrixSelection + def _calculate( + self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection ) -> IntercompanyMatrixOut: - """Общий вызов расчёта после проверки видимости версии в сервисе сводной ОСВ.""" - members = await repo.members(self._session, version_id) - companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for _, entity in members] + """Общий вызов расчёта с участниками из уже построенного свода.""" + 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, version, _ = await self._osv_service.build(version_id) - members = await repo.members(self._session, version.id) - accounts = list((await self._session.scalars(select(ChartOfAccount))).all()) - companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for _, entity in members] + 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( @@ -290,8 +314,50 @@ class IntercompanyMatrixService: credit_account_ids: Sequence[uuid.UUID] = (), ) -> IntercompanyMatrixOut: """ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков.""" - consolidated, version, _ = await self._osv_service.build(version_id) - accounts = list((await self._session.scalars(select(ChartOfAccount))).all()) + 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: @@ -306,4 +372,5 @@ class IntercompanyMatrixService: else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") selection = MatrixSelection(debit, credit, "closing") - return await self._calculate(consolidated.rows, version.id, selection) + 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 index be92abf..081be3f 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -2,14 +2,19 @@ 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 IntercompanyMatrixOut +from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut +from src.services.intercompany_matrix import MatrixExportInfo from src.services.osv_export import MONEY_FORMAT -METRICS = ("Разница", "Дебет", "Кредит") +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") @@ -23,28 +28,59 @@ def _text(sheet: Worksheet, row: int, column: int, value: str) -> None: cell.data_type = "s" -def build_matrix_workbook(matrix: IntercompanyMatrixOut) -> bytes: +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 = "Шахматка" - _text(sheet, 1, 1, "Дебет ↓ / Кредит →") - _text(sheet, 1, 2, "Показатель") + _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 = 18 + sheet.column_dimensions["B"].width = 40 for column, company in enumerate(matrix.companies, start=3): - _text(sheet, 1, column, company.name) + _text(sheet, TABLE_HEADER_ROW, column, company.name) sheet.column_dimensions[get_column_letter(column)].width = 26 - sheet.row_dimensions[1].height = 36 - for header in sheet[1]: + 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 = 2 + company_index * len(METRICS) + 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) @@ -68,15 +104,14 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut) -> bytes: continue if cell is None: continue - for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): - sheet.cell(start_row + offset, column, amount).number_format = MONEY_FORMAT - if cell.is_more_5_percent: + _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 = "C2" + sheet.freeze_panes = f"C{DATA_START_ROW}" buffer = BytesIO() workbook.save(buffer) return buffer.getvalue() 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 index 9141e46..971a135 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -4,9 +4,16 @@ 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 IntercompanyMatrixCompanyOut, IntercompanyMatrixOut, IntercompanyResultTableOut +from src.schemas.consolidations import ( + IntercompanyMatrixCellOut, + IntercompanyMatrixCompanyOut, + IntercompanyMatrixOut, + IntercompanyResultTableOut, +) from src.services import intercompany_matrix as matrix @@ -17,7 +24,7 @@ def _account(kind: AccountKind, parent_id: uuid.UUID | None = None) -> SimpleNam def _row( owner: uuid.UUID, counterparty: uuid.UUID, - account_id: uuid.UUID, + account_id: uuid.UUID | None, *, debit: str = "0", credit: str = "0", @@ -39,7 +46,6 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: 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 - assert not any("/intercompany-matrix/presets/" in path for path, _ in paths) debit_parent = _account(AccountKind.A) debit_child = _account(AccountKind.A, debit_parent.id) @@ -69,16 +75,106 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] assert result.cells[0].amount_a == Decimal("100") assert result.cells[0].amount_b == Decimal("90") - assert result.cells[0].variance_percent == Decimal("100") / Decimal("9") + assert result.cells[0].variance_percent == Decimal("11.11") assert isinstance(result, IntercompanyMatrixOut) response = result - assert "exceeds_threshold" not in response.model_dump(mode="json")["cells"][0] 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 = { diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 70776b4..20fcc97 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -2,8 +2,11 @@ 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 ( @@ -11,15 +14,29 @@ from src.api.endpoints.consolidations.intercompany_matrix import ( 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") @@ -37,9 +54,11 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold amount_b="90.00", variance_amount="10.25", variance_percent="11.388888", - is_more_5_percent=True, - entry_osv_ids_debit=[entry_id], - entry_osv_ids_credit=[], + 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, @@ -48,64 +67,106 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold amount_b="20", variance_amount="0", variance_percent="0", - is_more_5_percent=False, - entry_osv_ids_debit=[], - entry_osv_ids_credit=[], + 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))) + 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[1]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"] - assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[1]) - assert sheet["C1"].data_type == "s" - assert sheet["A2"].value == "=Компания А" - assert sheet["A2"].data_type == "s" - assert sheet["A2"].alignment.horizontal == "center" - assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A2:A4", "A5:A7"} - assert [sheet.cell(row, 2).value for row in (2, 3, 4)] == list(METRICS) - assert [sheet.cell(row, 2).value for row in (5, 6, 7)] == list(METRICS) - assert all(sheet.cell(row, 2).alignment.horizontal == "center" for row in (2, 3, 4, 5, 6, 7)) - assert sheet["A5"].value == "Компания Б" - assert [sheet.cell(row, 3).value for row in (2, 3, 4)] == [None, None, None] - assert all(sheet.cell(row, 3).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in (2, 3, 4)) - assert all(sheet.cell(row, 4).fill.fgColor.rgb == DIAGONAL_FILL.fgColor.rgb for row in (5, 6, 7)) - expected_ab = (10.25, 100.25, 90) - assert tuple(sheet.cell(row, 4).value for row in (2, 3, 4)) == expected_ab - assert all(sheet.cell(row, 4).fill.fgColor.rgb == THRESHOLD_FILL.fgColor.rgb for row in (2, 3, 4)) - assert all(sheet.cell(row, column).comment is None for row in range(2, 8) for column in (3, 4)) - assert [sheet.cell(row, 3).value for row in (5, 6, 7)] == [0, 20, 20] - assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in (5, 6, 7)) - for column, first_row in ((3, 2), (4, 2), (3, 5), (4, 5)): - top, middle, bottom = (sheet.cell(first_row + offset, column) for offset in range(3)) + 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( - cell.border.left.style == "thin" and cell.border.right.style == "thin" for cell in (top, middle, bottom) + 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") - matrix = IntercompanyMatrixOut(companies=[], cells=[]) + 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_accounts(*args: object, **kwargs: object) -> IntercompanyMatrixOut: + async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: calls.append((args, kwargs)) - return matrix + return matrix, _info(mode=kwargs["mode"]) - service = SimpleNamespace(build_accounts=build_accounts) + 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) - manual_response = await export_intercompany_matrix_manual(version_id, selection, service) + 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"}), @@ -113,9 +174,58 @@ async def test_both_export_routes_use_matrix_calculation() -> None: ] for response in (all_response, manual_response): assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - assert "attachment" in response.headers["content-disposition"] - assert load_workbook(BytesIO(response.body)).active is not None + 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/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index 05b12ba..085fe86 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -145,3 +145,15 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD #### Scenario: Автоматический переход - **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION` - **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` + +### 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/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 2dbd2bf..c6e648f 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -162,3 +162,51 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт - **WHEN** экспорт версии с 3 строками и 1 пробелом открыт `openpyxl` - **THEN** лист «Сводная ОСВ» содержит заголовок и 3 строки, суммы — числа с двумя знаками, равные API; лист «Пробелы» — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` + +### 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/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 19331bd..5b64bd6 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -223,3 +223,31 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` #### Scenario: Невидимая версия при занятой строке - **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock` - **THEN** 404 `VERSION_NOT_FOUND` без ожидания + +### 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/specs/directories-api/spec.md b/openspec/specs/directories-api/spec.md index 554ee0a..245fa18 100644 --- a/openspec/specs/directories-api/spec.md +++ b/openspec/specs/directories-api/spec.md @@ -16,17 +16,6 @@ API справочников ядра: группы компаний (ГК), ю - **WHEN** пользователь с `DIRECTORY_VIEW` запрашивает список после `seed --dev` - **THEN** ответ 200 содержит Головной офис с `is_head_office = true` и два dev-филиала -### Requirement: Плоский список счетов -`GET /api/v1/directories/accounts` SHALL возвращать пользователю с правом `DIRECTORY_VIEW` все строки -`chart_of_account` одним массивом в естественном порядке кодов. Каждая строка содержит `id`, `code`, -`parent_id`, `name`, `kind`, `row_type`, `is_off_balance`, `is_debit`, `is_credit`; корневые счета имеют `parent_id = null`. -`is_debit = true` для видов `A` и `AP`, `false` для `P` и незаданного вида. -`is_credit = true` для видов `P` и `AP`, `false` для `A` и незаданного вида. - -#### Scenario: Счета разных уровней -- **WHEN** в плане счетов есть счёт, субсчёт и аналитика -- **THEN** ответ содержит все три строки без вложенности, а `parent_id` указывает на родительскую строку - ### Requirement: Группа компаний — создание, чтение, изменение, архив, история `POST /api/v1/directories/company-groups` (право `DIRECTORY_EDIT`) SHALL создавать ГК с полями `name` (обязательно), `crm_id` (необязательно, уникально), `base_regional_office_id` (необязательно), генерируя `code` вида `GK-NNNNNN` и @@ -200,3 +189,18 @@ SHALL выполняться в одной транзакции с запись #### Scenario: Ошибка внутри мутации - **WHEN** при переводе ЮЛ закрытие членства завершается ошибкой БД - **THEN** ЮЛ остаётся в прежней ГК, строк истории и аудита нет + +### 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