объеденение эндпоинтов для all, manual и добавления для них суммы по дебету, кредиты в по юл

This commit is contained in:
PotapovaA 2026-11-03 17:45:54 +03:00
parent 0f107f8cf8
commit 994d6be94c
11 changed files with 364 additions and 148 deletions

View File

@ -934,11 +934,11 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
## Шахматка и результирующая таблица ВГО (Срез 5) ## Шахматка и результирующая таблица ВГО (Срез 5)
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица `POST …/intercompany-matrix` с необязательными списками счетов Дт/Кт и ID ЮЛ и его XLSX `POST …/intercompany-matrix/export`, статичная таблица
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` `GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации): (аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации):
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые. `one_sided` — ровно одна сумма
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии
`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату `consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату

View File

@ -13,8 +13,8 @@ from src.common.filenames import content_disposition
from src.models.enums import AuditAction, AuditSection from src.models.enums import AuditAction, AuditSection
from src.schemas.common import ErrorDetail from src.schemas.common import ErrorDetail
from src.schemas.consolidations import ( from src.schemas.consolidations import (
IntercompanyMatrixManualIn,
IntercompanyMatrixOut, IntercompanyMatrixOut,
IntercompanyMatrixSelectionIn,
IntercompanyResultTableOut, IntercompanyResultTableOut,
) )
from src.services.intercompany_matrix import MatrixExportInfo from src.services.intercompany_matrix import MatrixExportInfo
@ -56,7 +56,7 @@ async def _export_response(
info: MatrixExportInfo, info: MatrixExportInfo,
audit: RequestAudit, audit: RequestAudit,
*, *,
selection: IntercompanyMatrixManualIn | None = None, selection: IntercompanyMatrixSelectionIn | None = None,
) -> Response: ) -> Response:
content = await run_in_threadpool(build_matrix_workbook, matrix, info) content = await run_in_threadpool(build_matrix_workbook, matrix, info)
if info.mode == "PRESET" and (info.rule_id is None or info.rule_no is None or info.rule_version_no is None): if info.mode == "PRESET" and (info.rule_id is None or info.rule_no is None or info.rule_version_no is None):
@ -73,6 +73,7 @@ async def _export_response(
"mode": info.mode, "mode": info.mode,
"debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [], "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 [], "credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [],
"legal_entity_ids": [str(entity_id) for entity_id in selection.legal_entity_ids] if selection else [],
**( **(
{ {
"rule_id": str(info.rule_id), "rule_id": str(info.rule_id),
@ -88,84 +89,48 @@ async def _export_response(
return response return response
@router.get(
"/{version_id}/intercompany-matrix/all",
response_model=IntercompanyMatrixOut,
dependencies=VIEW,
responses=ERRORS,
summary="Рассчитать шахматку по всем счетам",
)
async def build_intercompany_matrix_all(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> IntercompanyMatrixOut:
"""Все счета с подходящим видом дебета или кредита."""
return await service.build_accounts(version_id, mode="ALL")
@router.get(
"/{version_id}/intercompany-matrix/all/export",
response_class=Response,
dependencies=VIEW,
responses=EXPORT_RESPONSES,
summary="Скачать шахматку по всем счетам в XLSX",
)
async def export_intercompany_matrix_all(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response:
matrix, info = await service.build_export(version_id, mode="ALL")
return await _export_response(version_id, matrix, info, audit)
@router.post( @router.post(
"/{version_id}/intercompany-matrix/manual", "/{version_id}/intercompany-matrix",
response_model=IntercompanyMatrixOut, response_model=IntercompanyMatrixOut,
dependencies=VIEW, dependencies=VIEW,
responses=ERRORS, responses=ERRORS,
summary="Рассчитать шахматку по выбранным счетам", summary="Рассчитать шахматку по выбранным или всем счетам",
) )
async def build_intercompany_matrix_manual( async def build_intercompany_matrix(
version_id: uuid.UUID, version_id: uuid.UUID,
selection: IntercompanyMatrixManualIn,
service: IntercompanyMatrixServiceDep, service: IntercompanyMatrixServiceDep,
selection: IntercompanyMatrixSelectionIn | None = None,
) -> IntercompanyMatrixOut: ) -> IntercompanyMatrixOut:
"""Выбранные счета раскрываются по иерархии.""" selection = selection or IntercompanyMatrixSelectionIn()
return await service.build_accounts( return await service.build_accounts(
version_id, version_id,
mode="MANUAL",
debit_account_ids=selection.debit_account_ids, debit_account_ids=selection.debit_account_ids,
credit_account_ids=selection.credit_account_ids, credit_account_ids=selection.credit_account_ids,
legal_entity_ids=selection.legal_entity_ids,
) )
@router.post( @router.post(
"/{version_id}/intercompany-matrix/manual/export", "/{version_id}/intercompany-matrix/export",
response_class=Response, response_class=Response,
dependencies=VIEW, dependencies=VIEW,
responses=EXPORT_RESPONSES, responses=EXPORT_RESPONSES,
summary="Скачать шахматку по выбранным счетам в XLSX", summary="Скачать шахматку по выбранным или всем счетам в XLSX",
) )
async def export_intercompany_matrix_manual( async def export_intercompany_matrix(
version_id: uuid.UUID, version_id: uuid.UUID,
selection: IntercompanyMatrixManualIn,
service: IntercompanyMatrixServiceDep, service: IntercompanyMatrixServiceDep,
audit: Annotated[RequestAudit, Depends(get_request_audit)], audit: Annotated[RequestAudit, Depends(get_request_audit)],
selection: IntercompanyMatrixSelectionIn | None = None,
) -> Response: ) -> Response:
selection = selection or IntercompanyMatrixSelectionIn()
matrix, info = await service.build_export( matrix, info = await service.build_export(
version_id, version_id,
mode="MANUAL",
debit_account_ids=selection.debit_account_ids, debit_account_ids=selection.debit_account_ids,
credit_account_ids=selection.credit_account_ids, credit_account_ids=selection.credit_account_ids,
legal_entity_ids=selection.legal_entity_ids,
) )
return await _export_response( return await _export_response(version_id, matrix, info, audit, selection=selection)
version_id,
matrix,
info,
audit,
selection=selection,
)
@router.get( @router.get(
@ -181,7 +146,7 @@ async def build_intercompany_matrix_preset(
service: IntercompanyMatrixServiceDep, service: IntercompanyMatrixServiceDep,
) -> IntercompanyMatrixOut: ) -> IntercompanyMatrixOut:
"""Шахматка §6 по счетам сторон правила §4.""" """Шахматка §6 по счетам сторон правила §4."""
return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) return await service.build_accounts(version_id, rule_id=rule_id)
@router.get( @router.get(
@ -198,7 +163,7 @@ async def export_intercompany_matrix_preset(
audit: Annotated[RequestAudit, Depends(get_request_audit)], audit: Annotated[RequestAudit, Depends(get_request_audit)],
) -> Response: ) -> Response:
"""XLSX шахматки §6 по правилу §4 с аудитом выгрузки.""" """XLSX шахматки §6 по правилу §4 с аудитом выгрузки."""
matrix, info = await service.build_export(version_id, mode="PRESET", rule_id=rule_id) matrix, info = await service.build_export(version_id, rule_id=rule_id)
return await _export_response(version_id, matrix, info, audit) return await _export_response(version_id, matrix, info, audit)

View File

@ -622,11 +622,12 @@ class EliminationTotalsOut(BaseModel):
items: list[EliminationTotalItemOut] items: list[EliminationTotalItemOut]
class IntercompanyMatrixManualIn(BaseModel): class IntercompanyMatrixSelectionIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" """Пустые списки счетов и ЮЛ означают все подходящие счета и весь периметр."""
debit_account_ids: list[uuid.UUID] = Field(min_length=1) debit_account_ids: list[uuid.UUID] = Field(default_factory=list)
credit_account_ids: list[uuid.UUID] = Field(min_length=1) credit_account_ids: list[uuid.UUID] = Field(default_factory=list)
legal_entity_ids: list[uuid.UUID] = Field(default_factory=list)
class IntercompanyMatrixCellOut(BaseModel): class IntercompanyMatrixCellOut(BaseModel):
@ -666,6 +667,8 @@ class IntercompanyMatrixOut(BaseModel):
companies: list[IntercompanyMatrixCompanyOut] companies: list[IntercompanyMatrixCompanyOut]
cells: list[IntercompanyMatrixCellOut] cells: list[IntercompanyMatrixCellOut]
total_debet: dict[uuid.UUID, Decimal] = Field(default_factory=dict)
total_credit: dict[uuid.UUID, Decimal] = Field(default_factory=dict)
class IntercompanyResultAmountOut(BaseModel): class IntercompanyResultAmountOut(BaseModel):

View File

@ -265,7 +265,14 @@ def calculate_matrix(
osv_entry_ids_credit=credit_ids, osv_entry_ids_credit=credit_ids,
) )
) )
return IntercompanyMatrixOut(companies=list(company_info), cells=cells) total_debet = {company_id: ZERO for company_id in company_ids}
total_credit = {company_id: ZERO for company_id in company_ids}
for cell in cells:
total_debet[cell.company_a_id] += cell.amount_a
total_credit[cell.company_b_id] += cell.amount_b
return IntercompanyMatrixOut(
companies=list(company_info), cells=cells, total_debet=total_debet, total_credit=total_credit
)
def _make_account_tree( def _make_account_tree(
@ -677,21 +684,17 @@ class IntercompanyMatrixService:
self, self,
version_id: uuid.UUID, version_id: uuid.UUID,
*, *,
mode: Literal["ALL", "MANUAL", "PRESET"],
debit_account_ids: Sequence[uuid.UUID] = (), debit_account_ids: Sequence[uuid.UUID] = (),
credit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (),
legal_entity_ids: Sequence[uuid.UUID] = (),
rule_id: uuid.UUID | None = None, rule_id: uuid.UUID | None = None,
) -> IntercompanyMatrixOut: ) -> IntercompanyMatrixOut:
"""Построить шахматку по выбранному режиму. """Построить шахматку: пустая сторона берёт все балансовые счета; правило задаёт пресет."""
ALL включает балансовые счета соответствующей стороны (без забалансовых); MANUAL — выбранные счета и их
потомков; PRESET берёт счета и показатель из правила с идентификатором `rule_id`.
"""
result = await self._build_accounts( result = await self._build_accounts(
version_id, version_id,
mode=mode,
debit_account_ids=debit_account_ids, debit_account_ids=debit_account_ids,
credit_account_ids=credit_account_ids, credit_account_ids=credit_account_ids,
legal_entity_ids=legal_entity_ids,
rule_id=rule_id, rule_id=rule_id,
) )
return result.matrix return result.matrix
@ -700,21 +703,24 @@ class IntercompanyMatrixService:
self, self,
version_id: uuid.UUID, version_id: uuid.UUID,
*, *,
mode: Literal["ALL", "MANUAL", "PRESET"],
debit_account_ids: Sequence[uuid.UUID] = (), debit_account_ids: Sequence[uuid.UUID] = (),
credit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (),
legal_entity_ids: Sequence[uuid.UUID] = (),
rule_id: uuid.UUID | None = None, rule_id: uuid.UUID | None = None,
) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
"""Матрица и реквизиты книги из одного расчёта свода.""" """Матрица и реквизиты книги из одного расчёта свода."""
result = await self._build_accounts( result = await self._build_accounts(
version_id, version_id,
mode=mode,
debit_account_ids=debit_account_ids, debit_account_ids=debit_account_ids,
credit_account_ids=credit_account_ids, credit_account_ids=credit_account_ids,
legal_entity_ids=legal_entity_ids,
rule_id=rule_id, rule_id=rule_id,
) )
group_name = await self._group_name(result.consolidation) group_name = await self._group_name(result.consolidation)
by_id = {account.id: account.code for account in result.accounts} by_id = {account.id: account.code for account in result.accounts}
mode: Literal["ALL", "MANUAL", "PRESET"] = (
"PRESET" if rule_id is not None else "MANUAL" if debit_account_ids or credit_account_ids else "ALL"
)
if mode == "PRESET": if mode == "PRESET":
debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids)) debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids))
credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids)) credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids))
@ -782,37 +788,43 @@ class IntercompanyMatrixService:
self, self,
version_id: uuid.UUID, version_id: uuid.UUID,
*, *,
mode: Literal["ALL", "MANUAL", "PRESET"],
debit_account_ids: Sequence[uuid.UUID], debit_account_ids: Sequence[uuid.UUID],
credit_account_ids: Sequence[uuid.UUID], credit_account_ids: Sequence[uuid.UUID],
legal_entity_ids: Sequence[uuid.UUID],
rule_id: uuid.UUID | None = None, rule_id: uuid.UUID | None = None,
) -> MatrixBuildResult: ) -> MatrixBuildResult:
osv_result = await self._osv_service.build_with_members(version_id) osv_result = await self._osv_service.build_with_members(version_id)
rows = osv_result.consolidated.rows rows = osv_result.consolidated.rows
members = osv_result.members
if legal_entity_ids:
selected_ids = set(legal_entity_ids)
if selected_ids - members.keys():
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "Выбранное ЮЛ не входит в периметр версии")
members = {entity_id: entity for entity_id, entity in members.items() if entity_id in selected_ids}
accounts = await directory_repo.list_accounts(self._session) accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts) by_id, children = _make_account_tree(accounts)
rule: EliminationRule | None = None rule: EliminationRule | None = None
if mode == "ALL": if rule_id is not None:
if debit_account_ids or credit_account_ids: if debit_account_ids or credit_account_ids:
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для пресета не нужно указывать счета")
debit = frozenset(
account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS
)
credit = frozenset(
account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS
)
selection = MatrixSelection(debit, credit, "closing")
elif mode == "MANUAL":
if not debit_account_ids or not credit_account_ids:
raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета")
debit = _select_accounts(debit_account_ids, (), "D", by_id, children)
credit = _select_accounts(credit_account_ids, (), "K", by_id, children)
selection = MatrixSelection(debit, credit, "closing")
elif mode == "PRESET":
selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children)
else: else:
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") debit = (
matrix = self._calculate(rows, osv_result.members, selection) _select_accounts(debit_account_ids, (), "D", by_id, children)
if debit_account_ids
else frozenset(
account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS
)
)
credit = (
_select_accounts(credit_account_ids, (), "K", by_id, children)
if credit_account_ids
else frozenset(
account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS
)
)
selection = MatrixSelection(debit, credit, "closing")
matrix = self._calculate(rows, members, selection)
return MatrixBuildResult( return MatrixBuildResult(
matrix=matrix, matrix=matrix,
version=osv_result.version, version=osv_result.version,

View File

@ -56,8 +56,8 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int:
mode_name = {"ALL": all_accounts, "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode] mode_name = {"ALL": all_accounts, "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode]
extra = [ extra = [
("Режим", mode_name), ("Режим", mode_name),
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else all_accounts), ("Счета дебета", ", ".join(info.debit_account_codes) or all_accounts),
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts), ("Счета кредита", ", ".join(info.credit_account_codes) or all_accounts),
("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"), ("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"),
] ]
if info.mode == "PRESET": if info.mode == "PRESET":
@ -88,6 +88,31 @@ def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMat
_skipped_comment(sheet, row, column, cell.skipped_osv_entry_count) _skipped_comment(sheet, row, column, cell.skipped_osv_entry_count)
def _write_matrix_totals(
sheet: Worksheet, matrix: IntercompanyMatrixOut, data_start_row: int, total_column: int
) -> None:
for company_index, company in enumerate(matrix.companies):
start_row = data_start_row + company_index * len(METRICS)
sheet.merge_cells(
start_row=start_row,
start_column=total_column,
end_row=start_row + len(METRICS) - 1,
end_column=total_column,
)
debit_total = sheet.cell(start_row, total_column, matrix.total_debet[company.id])
debit_total.number_format = MONEY_FORMAT
debit_total.font = Font(bold=True)
debit_total.alignment = Alignment(horizontal="right", vertical="center")
credit_total_row = data_start_row + len(matrix.companies) * len(METRICS)
_text(sheet, credit_total_row, 1, "Кредит")
sheet.cell(credit_total_row, 1).font = Font(bold=True)
for column, company in enumerate(matrix.companies, start=3):
credit_total = sheet.cell(credit_total_row, column, matrix.total_credit[company.id])
credit_total.number_format = MONEY_FORMAT
credit_total.font = Font(bold=True)
def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> bytes: def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> bytes:
workbook = Workbook() workbook = Workbook()
sheet = workbook.active sheet = workbook.active
@ -99,10 +124,13 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
_text(sheet, table_header_row, 2, "Показатель") _text(sheet, table_header_row, 2, "Показатель")
sheet.column_dimensions["A"].width = 32 sheet.column_dimensions["A"].width = 32
sheet.column_dimensions["B"].width = 40 sheet.column_dimensions["B"].width = 40
debit_total_column = len(matrix.companies) + 3
for column, company in enumerate(matrix.companies, start=3): for column, company in enumerate(matrix.companies, start=3):
_text(sheet, table_header_row, column, company.name) _text(sheet, table_header_row, column, company.name)
sheet.column_dimensions[get_column_letter(column)].width = 26 sheet.column_dimensions[get_column_letter(column)].width = 26
_text(sheet, table_header_row, debit_total_column, "Дебет")
sheet.column_dimensions[get_column_letter(debit_total_column)].width = 26
sheet.row_dimensions[table_header_row].height = 36 sheet.row_dimensions[table_header_row].height = 36
for header in sheet[table_header_row]: for header in sheet[table_header_row]:
header.font = Font(bold=True) header.font = Font(bold=True)
@ -142,6 +170,8 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
marked.fill = THRESHOLD_FILL marked.fill = THRESHOLD_FILL
marked.font = THRESHOLD_FONT marked.font = THRESHOLD_FONT
_write_matrix_totals(sheet, matrix, data_start_row, debit_total_column)
sheet.freeze_panes = f"C{data_start_row}" sheet.freeze_panes = f"C{data_start_row}"
buffer = BytesIO() buffer = BytesIO()
workbook.save(buffer) workbook.save(buffer)

View File

@ -28,7 +28,7 @@ pytestmark = pytest.mark.integration
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
KA_RF = "ka_rf@armka.local" KA_RF = "ka_rf@armka.local"
RECEIVABLES_PAYABLES_RULE_NO = 6 RECEIVABLES_PAYABLES_RULE_NO = 6
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export") ROUTES = ("matrix", "result-table", "matrix/export", "result-table/export")
@dataclass @dataclass
@ -60,8 +60,11 @@ async def _world(session: AsyncSession, *, members: int = 2) -> World:
async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response: async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response:
url = f"/consolidations/versions/{version_id}/intercompany-matrix/{suffix}" base = f"/consolidations/versions/{version_id}/intercompany-matrix"
return await client.post(url, json=selection) if suffix.startswith("manual") else await client.get(url) if suffix.startswith("matrix"):
url = base + ("/export" if suffix.endswith("/export") else "")
return await client.post(url, json=selection)
return await client.get(f"{base}/{suffix}")
async def _source( async def _source(
@ -91,12 +94,12 @@ async def _source(
return document.id return document.id
@pytest.mark.parametrize("suffix", ["all", "manual"]) @pytest.mark.parametrize("selection", [{}, {"debit_account_ids": []}])
async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, selection: dict) -> None:
world = await _world(seeded, members=0) world = await _world(seeded, members=0)
response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", selection)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
assert response.json() == {"companies": [], "cells": []} assert response.json() == {"companies": [], "cells": [], "total_debet": {}, "total_credit": {}}
@pytest.mark.parametrize("suffix", ROUTES) @pytest.mark.parametrize("suffix", ROUTES)
@ -126,7 +129,7 @@ async def test_matrix_reuses_members_and_reads_accounts_through_repository(
async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded) world = await _world(seeded)
await _source(seeded, world, credit=False, amount="999", account="58.03") 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) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
assert len(response.json()["cells"]) == len(world.members) assert len(response.json()["cells"]) == len(world.members)
for cell in response.json()["cells"]: for cell in response.json()["cells"]:
@ -143,6 +146,37 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed
assert cell["skipped_osv_entry_count"] == 0 assert cell["skipped_osv_entry_count"] == 0
async def test_company_filter_applies_to_matrix_and_export(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded, members=3)
await _source(seeded, world, credit=False, amount="100")
selected = [str(member.id) for member in world.members[:2]]
selection = {**world.selection, "legal_entity_ids": selected}
client = await auth_client(KA_RF)
response = await _request(client, world.version.id, "matrix", selection)
export = await _request(client, world.version.id, "matrix/export", selection)
assert response.status_code == export.status_code == OK
data = response.json()
assert [company["id"] for company in data["companies"]] == selected
assert len(data["cells"]) == 2 # noqa: PLR2004 - одна направленная ячейка на каждую сторону пары
assert set(data["total_debet"]) == set(data["total_credit"]) == set(selected)
sheet = load_workbook(BytesIO(export.content))["Шахматка"]
assert [sheet.cell(10, column).value for column in range(3, 6)] == [
world.members[0].name,
world.members[1].name,
"Дебет",
]
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["legal_entity_ids"] == selected
invalid = await _request(client, world.version.id, "matrix", {"legal_entity_ids": [str(uuid.uuid4())]})
assert invalid.status_code == UNPROCESSABLE
assert invalid.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER"
async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded) world = await _world(seeded)
rule = ( rule = (
@ -573,7 +607,7 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
await _source(seeded, world, credit=False, amount="100", account="001") await _source(seeded, world, credit=False, amount="100", account="001")
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
all_response = await _request(client, world.version.id, "all", world.selection) all_response = await _request(client, world.version.id, "matrix", {})
assert all_response.status_code == OK, all_response.text assert all_response.status_code == OK, all_response.text
assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"]) assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"])
@ -581,10 +615,12 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
"debit_account_ids": [str(world.accounts["001"].id)], "debit_account_ids": [str(world.accounts["001"].id)],
"credit_account_ids": [str(world.accounts["60.01"].id)], "credit_account_ids": [str(world.accounts["60.01"].id)],
} }
manual_response = await _request(client, world.version.id, "manual", manual_selection) manual_response = await _request(client, world.version.id, "matrix", manual_selection)
assert manual_response.status_code == OK, manual_response.text assert manual_response.status_code == OK, manual_response.text
cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id))
assert Decimal(cell["amount_a"]) == Decimal("100") assert Decimal(cell["amount_a"]) == Decimal("100")
assert manual_response.json()["total_debet"][str(world.members[0].id)] == "100.00"
assert all(amount == "0.00" for amount in manual_response.json()["total_credit"].values())
async def test_unmapped_account_source_is_visible_in_cell_count( async def test_unmapped_account_source_is_visible_in_cell_count(
@ -596,7 +632,7 @@ async def test_unmapped_account_source_is_visible_in_cell_count(
entry.account_id = None entry.account_id = None
await seeded.flush() await seeded.flush()
await _source(seeded, world, credit=True, amount="80") await _source(seeded, world, credit=True, amount="80")
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
cells = response.json()["cells"] cells = response.json()["cells"]
assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1] assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1]
@ -614,7 +650,7 @@ async def test_pair_without_counterpart(
) -> None: ) -> None:
world = await _world(seeded) world = await _world(seeded)
await _source(seeded, world, credit=credit, amount=amount) await _source(seeded, world, credit=credit, amount=amount)
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection)
assert response.status_code == OK, response.text 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)) 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_a"]) == (Decimal(0) if credit else Decimal(amount))
@ -646,7 +682,7 @@ async def test_rounded_percent_in_api(
world = await _world(seeded) world = await _world(seeded)
await _source(seeded, world, credit=False, amount=debit) await _source(seeded, world, credit=False, amount=debit)
await _source(seeded, world, credit=True, amount=credit) await _source(seeded, world, credit=True, amount=credit)
response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection)
assert response.status_code == OK, response.text 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)) 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["variance_percent"] == percent
@ -654,7 +690,7 @@ async def test_rounded_percent_in_api(
assert cell["one_sided"] is False assert cell["one_sided"] is False
@pytest.mark.parametrize("suffix", ["manual", "manual/export"]) @pytest.mark.parametrize("suffix", ["matrix", "matrix/export"])
@pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"]) @pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"])
@pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"]) @pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"])
async def test_invalid_account_selection( async def test_invalid_account_selection(
@ -726,7 +762,9 @@ async def test_export_filename_header_and_percentage(
group_name = ( group_name = (
await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id)) await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id))
).one() ).one()
response = await _request(await auth_client(KA_RF), world.version.id, f"{mode}/export", world.selection) response = await _request(
await auth_client(KA_RF), world.version.id, "matrix/export", world.selection if mode == "manual" else {}
)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
disposition = response.headers["content-disposition"] disposition = response.headers["content-disposition"]
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == ( assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == (
@ -755,7 +793,9 @@ async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: Asyn
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT) query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT)
for _ in range(2): for _ in range(2):
response = await _request(client, world.version.id, f"{mode}/export", world.selection) response = await _request(
client, world.version.id, "matrix/export", world.selection if mode == "manual" else {}
)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
events = (await seeded.scalars(query)).all() events = (await seeded.scalars(query)).all()
assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку
@ -769,5 +809,6 @@ async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: Asyn
"report": "intercompany_matrix", "report": "intercompany_matrix",
"mode": mode.upper(), "mode": mode.upper(),
"cells": len(world.members), "cells": len(world.members),
"legal_entity_ids": [],
**(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}), **(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}),
} }

View File

@ -9,12 +9,13 @@ from unittest.mock import AsyncMock, Mock
import pytest import pytest
from fastapi import status as http_status from fastapi import status as http_status
from src.api.endpoints.consolidations.intercompany_matrix import router from src.api.endpoints.consolidations.intercompany_matrix import build_intercompany_matrix, router
from src.models.enums import AccountKind, StatementType from src.models.enums import AccountKind, PeriodQuarter, StatementType
from src.schemas.consolidations import ( from src.schemas.consolidations import (
IntercompanyMatrixCellOut, IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut, IntercompanyMatrixCompanyOut,
IntercompanyMatrixOut, IntercompanyMatrixOut,
IntercompanyMatrixSelectionIn,
IntercompanyResultTableOut, IntercompanyResultTableOut,
) )
from src.services import intercompany_matrix as matrix from src.services import intercompany_matrix as matrix
@ -44,10 +45,11 @@ def _row(
) )
def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: def test_routes_and_matrix_expands_manual_accounts() -> None:
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes}
assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix", ("POST",)) in paths
assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/export", ("POST",)) in paths
assert not any(path.endswith(("/all", "/manual", "/all/export", "/manual/export")) for path, _ in paths)
assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}", ("GET",)) in paths
assert not any(path.endswith("/unpaired-osv") for path, _ in paths) assert not any(path.endswith("/unpaired-osv") for path, _ in paths)
assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths
@ -89,6 +91,79 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
] ]
def test_matrix_totals_accumulate_each_company_and_side() -> None:
company_a, company_b, company_c = (uuid.uuid4() for _ in range(3))
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
companies = [
IntercompanyMatrixCompanyOut(id=company_id, name=str(index))
for index, company_id in enumerate((company_a, company_b, company_c))
]
result = matrix.calculate_matrix(
[
_row(company_a, company_b, debit_account, debit="100"),
_row(company_a, company_c, debit_account, debit="20"),
_row(company_c, company_a, debit_account, debit="5"),
_row(company_b, company_a, credit_account, credit="70"),
_row(company_c, company_a, credit_account, credit="10"),
_row(company_a, company_b, credit_account, credit="3"),
],
companies,
matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"),
)
assert result.total_debet == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")}
assert result.total_credit == {company_a: Decimal("3"), company_b: Decimal("70"), company_c: Decimal("10")}
assert result.model_dump(mode="json")["total_debet"][str(company_a)] == "120.00"
@pytest.mark.parametrize(
"payload",
[
None,
{},
{"debit_account_ids": []},
{"credit_account_ids": [uuid.uuid4()]},
{"legal_entity_ids": [uuid.uuid4()]},
],
)
async def test_unified_matrix_route_passes_selection(payload: dict | None) -> None:
version_id = uuid.uuid4()
selection = IntercompanyMatrixSelectionIn(**payload) if payload is not None else None
result = IntercompanyMatrixOut(companies=[], cells=[])
service = SimpleNamespace(build_accounts=AsyncMock(return_value=result))
assert await build_intercompany_matrix(version_id, service, selection) is result
expected_selection = selection or IntercompanyMatrixSelectionIn()
service.build_accounts.assert_awaited_once_with(
version_id,
debit_account_ids=expected_selection.debit_account_ids,
credit_account_ids=expected_selection.credit_account_ids,
legal_entity_ids=expected_selection.legal_entity_ids,
)
async def test_export_mode_is_derived_from_account_lists(monkeypatch: pytest.MonkeyPatch) -> None:
debit_id = uuid.uuid4()
built = SimpleNamespace(
matrix=IntercompanyMatrixOut(companies=[], cells=[]),
consolidation=SimpleNamespace(year=2024, quarter=PeriodQuarter.Q1),
version=SimpleNamespace(version_no=1),
accounts=[SimpleNamespace(id=debit_id, code="62.01")],
selection=matrix.MatrixSelection(frozenset(), frozenset(), "closing"),
rule=None,
)
service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, SimpleNamespace())
monkeypatch.setattr(service, "_build_accounts", AsyncMock(return_value=built))
monkeypatch.setattr(service, "_group_name", AsyncMock(return_value="ГК"))
_, all_info = await service.build_export(uuid.uuid4())
_, manual_info = await service.build_export(uuid.uuid4(), debit_account_ids=[debit_id])
assert (all_info.mode, manual_info.mode) == ("ALL", "MANUAL")
assert manual_info.debit_account_codes == ("62.01",)
assert manual_info.credit_account_codes == ()
async def test_build_pairs_uses_supplied_rows_and_prebuilt_osv(monkeypatch: pytest.MonkeyPatch) -> None: async def test_build_pairs_uses_supplied_rows_and_prebuilt_osv(monkeypatch: pytest.MonkeyPatch) -> None:
version_id, rule_id, company_a, company_b = (uuid.uuid4() for _ in range(4)) version_id, rule_id, company_a, company_b = (uuid.uuid4() for _ in range(4))
full_rows = [object()] full_rows = [object()]
@ -158,14 +233,14 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
captured = [] captured = []
monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection)) monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection))
await service.build_accounts(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) await service.build_accounts(uuid.uuid4(), rule_id=uuid.uuid4())
assert len(captured) == 1 assert len(captured) == 1
assert captured[0].company_1_account_ids == {parent.id} assert captured[0].company_1_account_ids == {parent.id}
assert captured[0].company_2_account_ids == {parent.id, child.id} assert captured[0].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover" assert captured[0].measure == "turnover"
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) _, info = await service.build_export(uuid.uuid4(), rule_id=uuid.uuid4())
assert info.mode == "PRESET" assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",) assert info.debit_account_codes == ("76",)
assert info.credit_account_codes == ("76", "76.01") assert info.credit_account_codes == ("76", "76.01")
@ -253,14 +328,25 @@ async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.Monkey
) )
service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service) service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service)
all_matrix = await service.build_accounts(uuid.uuid4(), mode="ALL") all_matrix = await service.build_accounts(uuid.uuid4())
manual_matrix = await service.build_accounts( manual_matrix = await service.build_accounts(
uuid.uuid4(), mode="MANUAL", debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id] uuid.uuid4(), debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id]
) )
debit_only = await service.build_accounts(uuid.uuid4(), debit_account_ids=[off_balance.id])
credit_only = await service.build_accounts(uuid.uuid4(), credit_account_ids=[off_balance_credit.id])
filtered = await service.build_accounts(uuid.uuid4(), legal_entity_ids=[company_a])
assert all_matrix.cells[0].amount_a == Decimal("30") assert all_matrix.cells[0].amount_a == Decimal("30")
assert all_matrix.cells[0].amount_b == Decimal("20") assert all_matrix.cells[0].amount_b == Decimal("20")
assert manual_matrix.cells[0].amount_a == Decimal("100") assert manual_matrix.cells[0].amount_a == Decimal("100")
assert manual_matrix.cells[0].amount_b == Decimal("40") assert manual_matrix.cells[0].amount_b == Decimal("40")
assert (debit_only.cells[0].amount_a, debit_only.cells[0].amount_b) == (Decimal("100"), Decimal("20"))
assert (credit_only.cells[0].amount_a, credit_only.cells[0].amount_b) == (Decimal("30"), Decimal("40"))
assert [company.id for company in filtered.companies] == [company_a]
assert filtered.cells == []
assert filtered.total_debet == filtered.total_credit == {company_a: Decimal("0")}
with pytest.raises(matrix.DomainValidationError) as error:
await service.build_accounts(uuid.uuid4(), legal_entity_ids=[uuid.uuid4()])
assert error.value.code == "ENTITY_NOT_IN_PERIMETER"
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -1,5 +1,7 @@
"""XLSX-выгрузка шахматки из готового результата расчёта.""" """XLSX-выгрузка шахматки из готового результата расчёта."""
from dataclasses import replace
from decimal import Decimal
from io import BytesIO from io import BytesIO
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@ -7,11 +9,11 @@ from urllib.parse import unquote
from uuid import UUID from uuid import UUID
import pytest import pytest
from fastapi import status as http_status
from openpyxl import load_workbook from openpyxl import load_workbook
from src.api.endpoints.consolidations.intercompany_matrix import ( from src.api.endpoints.consolidations.intercompany_matrix import (
export_intercompany_matrix_all, export_intercompany_matrix,
export_intercompany_matrix_manual,
export_intercompany_matrix_preset, export_intercompany_matrix_preset,
export_intercompany_result_table, export_intercompany_result_table,
router, router,
@ -20,8 +22,8 @@ from src.models.enums import AuditAction, AuditSection, PeriodQuarter, Statement
from src.schemas.consolidations import ( from src.schemas.consolidations import (
IntercompanyMatrixCellOut, IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut, IntercompanyMatrixCompanyOut,
IntercompanyMatrixManualIn,
IntercompanyMatrixOut, IntercompanyMatrixOut,
IntercompanyMatrixSelectionIn,
IntercompanyResultAmountOut, IntercompanyResultAmountOut,
IntercompanyResultArticleOut, IntercompanyResultArticleOut,
IntercompanyResultTableOut, IntercompanyResultTableOut,
@ -34,6 +36,7 @@ from src.services.intercompany_matrix_export import (
build_matrix_workbook, build_matrix_workbook,
build_result_table_workbook, build_result_table_workbook,
) )
from src.services.osv_export import MONEY_FORMAT
def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportInfo: def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportInfo:
@ -92,13 +95,21 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
osv_entry_ids_credit=[], osv_entry_ids_credit=[],
), ),
], ],
total_debet={company_a: "100.25", company_b: "20.00"},
total_credit={company_a: "20.00", company_b: "90.00"},
) )
workbook = load_workbook(BytesIO(build_matrix_workbook(matrix, _info()))) workbook = load_workbook(BytesIO(build_matrix_workbook(matrix, _info())))
sheet = workbook.active sheet = workbook.active
assert sheet is not None assert sheet is not None
assert sheet.title == "Шахматка" assert sheet.title == "Шахматка"
assert [cell.value for cell in sheet[10]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"] 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 all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[10])
assert sheet["C10"].data_type == "s" assert sheet["C10"].data_type == "s"
assert sheet["A1"].value == "Шахматка внутригрупповых операций" assert sheet["A1"].value == "Шахматка внутригрупповых операций"
@ -114,7 +125,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
assert sheet["A11"].value == "=Компания А" assert sheet["A11"].value == "=Компания А"
assert sheet["A11"].data_type == "s" assert sheet["A11"].data_type == "s"
assert sheet["A11"].alignment.horizontal == "center" assert sheet["A11"].alignment.horizontal == "center"
assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18"} assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18", "E11:E14", "E15:E18"}
assert [sheet.cell(row, 2).value for row in range(11, 15)] == list(METRICS) 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 [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 all(sheet.cell(row, 2).alignment.horizontal == "center" for row in range(11, 19))
@ -134,6 +145,10 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
if (row, column) != (11, 4) if (row, column) != (11, 4)
) )
assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0] assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0]
assert Decimal(str(sheet["E11"].value)) == Decimal("100.25")
assert Decimal(str(sheet["E15"].value)) == Decimal("20")
assert [sheet.cell(19, column).value for column in range(1, 6)] == ["Кредит", None, 20, 90, None]
assert all(sheet[cell].number_format == MONEY_FORMAT for cell in ("E11", "E15", "C19", "D19"))
assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19)) assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19))
assert sheet.freeze_panes == "C11" assert sheet.freeze_panes == "C11"
for column, first_row in ((3, 11), (4, 11), (3, 15), (4, 15)): for column, first_row in ((3, 11), (4, 11), (3, 15), (4, 15)):
@ -174,6 +189,8 @@ def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str,
osv_entry_ids_credit=[], osv_entry_ids_credit=[],
) )
], ],
total_debet={company_a: amount, company_b: "0"},
total_credit={company_a: "0", company_b: "0"},
) )
sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="ALL")))).active sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="ALL")))).active
@ -206,25 +223,37 @@ async def test_export_routes_use_matrix_calculation() -> None:
osv_entry_ids_credit=[], osv_entry_ids_credit=[],
) )
], ],
total_debet={company_a.id: "0", company_b.id: "0"},
total_credit={company_a.id: "0", company_b.id: "0"},
) )
calls = [] calls = []
async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
calls.append((args, kwargs)) calls.append((args, kwargs))
return matrix, _info(mode=kwargs["mode"], rule_id=kwargs.get("rule_id")) mode = (
"PRESET"
if kwargs.get("rule_id")
else "MANUAL"
if kwargs.get("debit_account_ids") or kwargs.get("credit_account_ids")
else "ALL"
)
return matrix, _info(mode=mode, rule_id=kwargs.get("rule_id"))
service = SimpleNamespace(build_export=build_export) service = SimpleNamespace(build_export=build_export)
audit = SimpleNamespace(record=AsyncMock()) audit = SimpleNamespace(record=AsyncMock())
selection = IntercompanyMatrixManualIn(debit_account_ids=[account_id], credit_account_ids=[account_id]) selection = IntercompanyMatrixSelectionIn(debit_account_ids=[account_id], credit_account_ids=[account_id])
all_response = await export_intercompany_matrix_all(version_id, service, audit) all_response = await export_intercompany_matrix(version_id, service, audit)
manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit) manual_response = await export_intercompany_matrix(version_id, service, audit, selection)
preset_response = await export_intercompany_matrix_preset(version_id, account_id, service, audit) preset_response = await export_intercompany_matrix_preset(version_id, account_id, service, audit)
assert calls == [ assert calls == [
((version_id,), {"mode": "ALL"}), ((version_id,), {"debit_account_ids": [], "credit_account_ids": [], "legal_entity_ids": []}),
((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}), (
((version_id,), {"mode": "PRESET", "rule_id": account_id}), (version_id,),
{"debit_account_ids": [account_id], "credit_account_ids": [account_id], "legal_entity_ids": []},
),
((version_id,), {"rule_id": account_id}),
] ]
for response, expected_info in ( for response, expected_info in (
(all_response, _info(mode="ALL")), (all_response, _info(mode="ALL")),
@ -262,6 +291,7 @@ async def test_export_routes_use_matrix_calculation() -> None:
"mode": "ALL", "mode": "ALL",
"debit_account_ids": [], "debit_account_ids": [],
"credit_account_ids": [], "credit_account_ids": [],
"legal_entity_ids": [],
"cells": 1, "cells": 1,
}, },
}, },
@ -276,6 +306,7 @@ async def test_export_routes_use_matrix_calculation() -> None:
"mode": "MANUAL", "mode": "MANUAL",
"debit_account_ids": [str(account_id)], "debit_account_ids": [str(account_id)],
"credit_account_ids": [str(account_id)], "credit_account_ids": [str(account_id)],
"legal_entity_ids": [],
"cells": 1, "cells": 1,
}, },
}, },
@ -290,6 +321,7 @@ async def test_export_routes_use_matrix_calculation() -> None:
"mode": "PRESET", "mode": "PRESET",
"debit_account_ids": [], "debit_account_ids": [],
"credit_account_ids": [], "credit_account_ids": [],
"legal_entity_ids": [],
"rule_id": str(account_id), "rule_id": str(account_id),
"rule_no": 6, "rule_no": 6,
"rule_version_no": 3, "rule_version_no": 3,
@ -299,11 +331,50 @@ async def test_export_routes_use_matrix_calculation() -> None:
] ]
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} 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/export", ("POST",)) in paths
assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual/export", ("POST",)) in paths
assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export", ("GET",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export", ("GET",)) in paths
@pytest.mark.parametrize(
("selection", "mode"),
[
({}, "ALL"),
({"debit_account_ids": [UUID("11111111-1111-1111-1111-111111111111")]}, "MANUAL"),
({"legal_entity_ids": [UUID("11111111-1111-1111-1111-111111111111")]}, "ALL"),
],
)
async def test_unified_export_uses_empty_side_as_all(selection: dict, mode: str) -> None:
version_id = UUID("22222222-2222-2222-2222-222222222222")
request = IntercompanyMatrixSelectionIn(**selection)
matrix = IntercompanyMatrixOut(companies=[], cells=[])
service = SimpleNamespace(build_export=AsyncMock(return_value=(matrix, _info(mode=mode))))
audit = SimpleNamespace(record=AsyncMock())
response = await export_intercompany_matrix(version_id, service, audit, request)
assert response.status_code == http_status.HTTP_200_OK
service.build_export.assert_awaited_once_with(
version_id,
debit_account_ids=request.debit_account_ids,
credit_account_ids=request.credit_account_ids,
legal_entity_ids=request.legal_entity_ids,
)
assert audit.record.await_args.kwargs["after_value"]["mode"] == mode
assert audit.record.await_args.kwargs["after_value"]["legal_entity_ids"] == [
str(entity_id) for entity_id in request.legal_entity_ids
]
def test_export_header_marks_empty_side_as_all() -> None:
matrix = IntercompanyMatrixOut(companies=[], cells=[])
info = replace(_info(), credit_account_codes=())
sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, info))).active
assert sheet["B7"].value == "62.01"
assert sheet["B8"].value == "Все балансовые счета"
async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None: async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None:
version_id = UUID("11111111-1111-1111-1111-111111111111") version_id = UUID("11111111-1111-1111-1111-111111111111")
service = SimpleNamespace( service = SimpleNamespace(
@ -316,7 +387,7 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon
monkeypatch.setattr("src.api.endpoints.consolidations.intercompany_matrix.build_matrix_workbook", fail_workbook) monkeypatch.setattr("src.api.endpoints.consolidations.intercompany_matrix.build_matrix_workbook", fail_workbook)
with pytest.raises(ValueError, match="xlsx failed"): with pytest.raises(ValueError, match="xlsx failed"):
await export_intercompany_matrix_all(version_id, service, audit) await export_intercompany_matrix(version_id, service, audit)
audit.record.assert_not_awaited() audit.record.assert_not_awaited()

View File

@ -89,10 +89,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"get"}, "/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"get"},
"/api/v1/consolidations/versions/{version_id}/vgo-assignments/rule": {"put", "delete"}, "/api/v1/consolidations/versions/{version_id}/vgo-assignments/rule": {"put", "delete"},
"/api/v1/consolidations/versions/{version_id}/vgo-assignments/exclude": {"put"}, "/api/v1/consolidations/versions/{version_id}/vgo-assignments/exclude": {"put"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/export": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},

View File

@ -147,12 +147,11 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
- **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` - **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED`
### Requirement: Аудит экспорта шахматки ### Requirement: Аудит экспорта шахматки
Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export`
`GET …/intercompany-matrix/preset/{rule_id}/export`
SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`,
`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, `object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора,
`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, `before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids,
credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, `rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка `MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
сводной ОСВ продолжает писать собственное событие `EXPORT`. сводной ОСВ продолжает писать собственное событие `EXPORT`.

View File

@ -175,17 +175,20 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт
— одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3`
### Requirement: Шахматка по сводной ОСВ версии ### Requirement: Шахматка по сводной ОСВ версии
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и `POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual` `{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст,
(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается
и её периметра. ALL включает балансовые счета (`is_off_balance = false`) вида `A`/`AP` для дебета и `P`/`AP` для по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL,
кредита; забалансовые счета (001–012) в ALL MUST NOT входить. MANUAL принимает обязательные непустые хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу
`debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета по иерархии и оставляет без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида
подходящий вид; забалансовые счета в MANUAL допускаются. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный `A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку.
вид — 422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`.
Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ.
ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL
отвечать одинаковым 404 `NOT_FOUND`. отвечать одинаковым 404 `NOT_FOUND`.
Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары Ответ SHALL содержать `companies[] {id, name}` для всего периметра, `total_debet` и `total_credit`
с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары
разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, разных компаний: `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`. `exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`.
Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам,
@ -197,6 +200,8 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт
сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count` сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count`
SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы
`osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны. `osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны.
`total_debet` суммирует `amount_a` ячеек по `company_a_id`, `total_credit` — `amount_b` по `company_b_id`.
Для ЮЛ без подходящих строк обе суммы равны `"0.00"`.
#### Scenario: Пустой периметр и отсутствие ОСВ #### Scenario: Пустой периметр и отсутствие ОСВ
- **WHEN** у версии нет членов периметра - **WHEN** у версии нет членов периметра
@ -231,11 +236,17 @@ SHALL считать исходные строки ОСВ пары с `account_i
- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) - **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026)
### Requirement: XLSX шахматки ### Requirement: XLSX шахматки
`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать `POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX
те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет
`legal_entity_ids` из запроса и использует
`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`.
Ответ — XLSX с именем
`intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим, `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для
каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API.
Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debet` по каждому ЮЛ строки;
под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки.
#### Scenario: Экспорт выбранных счетов #### Scenario: Экспорт выбранных счетов
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 - **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1