правки по код ревью

This commit is contained in:
PotapovaA 2026-10-16 13:19:49 +03:00
parent 1ebd5b9997
commit f9f25e8245
14 changed files with 962 additions and 105 deletions

View File

@ -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 | правка версии без своей активной блокировки |

View File

@ -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(

View File

@ -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):

View File

@ -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)."""

View File

@ -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

View File

@ -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()

View File

@ -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": []}),
}

View File

@ -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 = {

View File

@ -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()

View File

@ -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

View File

@ -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 и числом ячеек

View File

@ -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<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
выбранные счета) и строками разницы, дебета, кредита и процента расхождения для каждой пары.
#### Scenario: Экспорт выбранных счетов
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования

View File

@ -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`

View File

@ -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