Merge pull request 'AURORA-1756' (#218) from AURORA-1756 into main

This commit is contained in:
aep 2026-11-06 16:21:14 +03:00
commit 593456d6e4
15 changed files with 568 additions and 136 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); непустой список может включать и забалансовые; `legal_entity_ids` сужает обе оси и итоги до выбранных ЮЛ. Итоги `total_debit` («Итого Дт», строка) и `total_credit` («Итого Кт», столбец) — по ID ЮЛ, в xlsx — те же колонка и строка; у результирующей таблицы итога по статьям нет. `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,18 @@ class IntercompanyMatrixOut(BaseModel):
companies: list[IntercompanyMatrixCompanyOut] companies: list[IntercompanyMatrixCompanyOut]
cells: list[IntercompanyMatrixCellOut] cells: list[IntercompanyMatrixCellOut]
total_debit: dict[uuid.UUID, Decimal] = Field(
default_factory=dict, description="Итого Дт: сумма amount_a ячеек строки компании (ключ — ID ЮЛ)"
)
total_credit: dict[uuid.UUID, Decimal] = Field(
default_factory=dict, description="Итого Кт: сумма amount_b ячеек столбца компании (ключ — ID ЮЛ)"
)
@field_validator("total_debit", "total_credit")
@classmethod
def _two_decimals(cls, value: dict[uuid.UUID, Decimal]) -> dict[uuid.UUID, Decimal]:
"""Итоги в JSON всегда с двумя знаками, в том числе нулевые."""
return {key: amount.quantize(CENT, rounding=ROUND_HALF_UP) for key, amount in value.items()}
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_debit = {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_debit[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_debit=total_debit, total_credit=total_credit
)
def _make_account_tree( def _make_account_tree(
@ -665,21 +672,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
@ -688,21 +691,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))
@ -770,37 +776,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_debit[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,14 +60,18 @@ 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(
session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None
) -> uuid.UUID: ) -> uuid.UUID:
owner, counterparty = reversed(world.members) if credit else world.members pair = world.members[:2]
owner, counterparty = reversed(pair) if credit else pair
code = account or ("60.01" if credit else "62.01") code = account or ("60.01" if credit else "62.01")
document = await make_confirmed_osv( document = await make_confirmed_osv(
session, session,
@ -91,12 +95,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_debit": {}, "total_credit": {}}
@pytest.mark.parametrize("suffix", ROUTES) @pytest.mark.parametrize("suffix", ROUTES)
@ -126,7 +130,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 +147,38 @@ 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_debit"]) == set(data["total_credit"]) == set(selected)
assert data["total_debit"] == {selected[0]: "100.00", selected[1]: "0.00"}
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 +609,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 +617,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_debit"][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 +634,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 +652,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 +684,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 +692,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(
@ -694,6 +732,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"] assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"]
assert receivables["group_total"] == "100.25" assert receivables["group_total"] == "100.25"
assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables) assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables)
assert {"company_totals", "grand_total"}.isdisjoint(data)
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"] sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [ assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
company["name"] for company in data["companies"] company["name"] for company in data["companies"]
@ -704,6 +743,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory,
for col, company in enumerate(data["companies"], start=2): for col, company in enumerate(data["companies"], start=2):
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]] assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"]) assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
assert sheet.max_row == 7 + len(data["articles"])
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])} assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
@ -718,7 +758,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]) == (
@ -747,7 +789,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 - отдельное событие на каждую выгрузку
@ -761,5 +805,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_debit == {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_debit"][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,26 @@ 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_debit == filtered.total_credit == {company_a: Decimal("0")}
assert filtered.model_dump(mode="json")["total_debit"] == {str(company_a): "0.00"}
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(
@ -414,6 +501,7 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None:
assert isinstance(result, IntercompanyResultTableOut) assert isinstance(result, IntercompanyResultTableOut)
response = result response = result
assert response.articles[0].amounts[0].company_id == company_a assert response.articles[0].amounts[0].company_id == company_a
assert {"company_totals", "grand_total"}.isdisjoint(result.model_dump(mode="json"))
def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None: def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None:

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_debit={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_debit={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_debit={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()
@ -359,6 +430,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
assert sheet["B7"].data_type == "s" assert sheet["B7"].data_type == "s"
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25] assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5)) assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
assert sheet.max_row == 8 # noqa: PLR2004 — под статьями нет строки «Итого»
assert sheet.freeze_panes == "B8" assert sheet.freeze_panes == "B8"
assert audit.record.await_args.kwargs == { assert audit.record.await_args.kwargs == {
"action": AuditAction.EXPORT, "action": AuditAction.EXPORT,
@ -377,7 +449,12 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte
version_id = UUID("11111111-1111-1111-1111-111111111111") version_id = UUID("11111111-1111-1111-1111-111111111111")
info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2) info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)
service = SimpleNamespace( service = SimpleNamespace(
build_result_export=AsyncMock(return_value=(IntercompanyResultTableOut(companies=[], articles=[]), info)) build_result_export=AsyncMock(
return_value=(
IntercompanyResultTableOut(companies=[], articles=[]),
info,
)
)
) )
audit = SimpleNamespace(record=AsyncMock()) audit = SimpleNamespace(record=AsyncMock())

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

@ -0,0 +1,18 @@
# Design
## Решения
- **Один маршрут вместо ALL/MANUAL.** Режим выводится из тела: обе стороны пусты — ALL, иначе MANUAL; пресет —
отдельный маршрут, как раньше. Одна пустая сторона = все балансовые счета этой стороны.
- **Фильтр ЮЛ на бэке.** `legal_entity_ids` сужает периметр до расчёта: оси, ячейки и итоги считаются только между
выбранными ЮЛ (DOC-221277: «оба итога должны пересчитываться по выбранным компаниям»).
- **Итоги — словарь по ID ЮЛ.** `total_debit[id]` — сумма `amount_a` строки, `total_credit[id]` — сумма `amount_b`
столбца; значения квантуются до двух знаков в схеме (иначе пустая выборка из одного ЮЛ дала бы `"0"`).
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 2026-11-06 | Поле `total_debet` переименовано в `total_debit` (ревью #218, M-1) | во всём API — `debit` (`debit_account_ids`, `osv_entry_ids_debit`); фронт поле ещё не читает | нулевая: переименование до первого потребителя |
| 2026-11-06 | Итоги квантуются валидатором схемы (M-2) | спека обещает `"0.00"`; при одном ЮЛ ячеек нет и сумма — `Decimal("0")` | минимальная |
| 2026-11-06 | `grand_total` результирующей таблицы убран из API и XLSX (I-1, решение владельца в чате) | аналитик 03.11 попросил убрать итог снизу; сумма ДЗ + КЗ + ФВ + займов смысла не имеет; в DOC-221277 итоговой строки нет | если заказчику понадобится итог — отдельная задача с правилом |

View File

@ -0,0 +1,31 @@
# Proposal
## Why
Итоги шахматки («Итого Дт» справа, «Итого Кт» снизу) считал фронт (PR #158, решение владельца 22.10.2026: агрегаты
отдаёт бэк). Аналитик задал правило итогов (DOC-221277, «Итоги шахматки», 03.11.2026) и попросил пересчитывать итоги
по выбранным в фильтре компаниям — значит, выбор компаний должен уйти в запрос. AURORA-1756.
## What Changes
- Единый `POST …/intercompany-matrix` и `POST …/intercompany-matrix/export` вместо `GET …/all`, `POST …/manual` и их
выгрузок: тело `{debit_account_ids?, credit_account_ids?, legal_entity_ids?}`; пустая сторона — все балансовые
счета; пустой `legal_entity_ids` — весь периметр. **BREAKING** для клиента фронта (переводится в AURORA-1757,
сливается одновременно).
- В ответе матрицы `total_debit` и `total_credit` — итог по каждому ЮЛ, строки с двумя знаками.
- В XLSX шахматки — колонка «Итого Дт» справа и строка «Итого Кт» снизу.
- Аудит выгрузки сохраняет `legal_entity_ids`.
- Результирующая таблица: итога по всем статьям нет (аналитик, 03.11.2026).
## Capabilities
### Modified Capabilities
- `consolidated-osv`: шахматка и её XLSX.
- `audit-trail`: payload аудита выгрузки шахматки.
- `consolidations-api`: результирующая таблица без итоговой строки.
## Impact
`arm-core/api`: маршруты, схемы и сервис шахматки, XLSX. Клиент фронта (`web/src/services/api/intercompanyMatrix.ts`)
переводится в AURORA-1757. PRESET не меняется.

View File

@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: Аудит экспорта шахматки
Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/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, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
`MANUAL` или `PRESET`, массивы счетов и ЮЛ — ID из запроса (счета в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
сводной ОСВ продолжает писать собственное событие `EXPORT`.
#### Scenario: Две выгрузки
- **WHEN** пользователь дважды выгружает ту же шахматку
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
#### Scenario: Выгрузка пресета
- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export`
- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек

View File

@ -0,0 +1,99 @@
## MODIFIED Requirements
### Requirement: Шахматка по сводной ОСВ версии
`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает
`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст,
для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается
по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL,
хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу
без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида
`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку.
Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`.
Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ.
ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL
отвечать одинаковым 404 `NOT_FOUND`.
Ответ SHALL содержать `companies[] {id, name}` для периметра (при непустом `legal_entity_ids` — только выбранные ЮЛ в порядке периметра), `total_debit` и `total_credit`
с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `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 тогда и только тогда, когда
ровно одна из сумм `amount_a`, `amount_b` равна нулю (знак ненулевой суммы не важен); наличие строк ОСВ с нулевым
сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count`
SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы
`osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны.
`total_debit` («Итого Дт», колонка справа) суммирует `amount_a` ячеек строки по `company_a_id`, `total_credit`
(«Итого Кт», строка снизу) — `amount_b` ячеек столбца по `company_b_id`; ключи — ID всех ЮЛ из `companies`.
Диагональ не участвует, отрицательные суммы входят со знаком, суммы ячеек точные (`numeric(18,2)`). Для ЮЛ без
подходящих строк, в том числе когда в выборке одно ЮЛ, итог равен `"0.00"` (правило аналитика, DOC-221277
«Итоги шахматки», 03.11.2026).
#### Scenario: Пустой периметр и отсутствие ОСВ
- **WHEN** у версии нет членов периметра
- **THEN** `companies = []`, `cells = []`, `total_debit = {}`, `total_credit = {}`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами
#### Scenario: Только одна сторона пары
- **WHEN** у A есть строка ОСВ с контрагентом B и ненулевым сальдо, а у B встречной строки нет
- **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст
#### Scenario: Встречная строка с нулевым сальдо
- **WHEN** у A по B сальдо 100 000 000, а у B по A есть строка ОСВ с нулевым сальдо (одни обороты)
- **THEN** `one_sided = true`, `exceeds_threshold = false`, пара подсвечивается
#### Scenario: Обе стороны нулевые
- **WHEN** у A и у B есть строки ОСВ друг на друга, но обе суммы равны 0
- **THEN** `one_sided = false`, `exceeds_threshold = false`, пара не подсвечивается
#### Scenario: Отрицательное сальдо
- **WHEN** у A по B сальдо −50, у B по A — 0
- **THEN** `one_sided = true`
#### Scenario: Забалансовые счета
- **WHEN** у A по B есть сальдо на счёте 001 (`is_off_balance = true`)
- **THEN** в режиме ALL оно в суммы не входит; в MANUAL с выбранным счётом 001 — входит
#### Scenario: Счёт не сопоставлен
- **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids`
- **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2
#### Scenario: Эталон «Русский бисквит»
- **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур
- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026)
#### Scenario: Итоги Дт и Кт
- **WHEN** в периметре A, B, C; `amount_a` ячеек (A, B) = 100 и (A, C) = 20, `amount_b` ячеек (A, B) = 70 и (C, B) = 5
- **THEN** `total_debit[A] = "120.00"`, `total_credit[B] = "75.00"`
#### Scenario: Фильтр по ЮЛ
- **WHEN** запрос с `legal_entity_ids = [A, B]` при периметре A, B, C
- **THEN** `companies` и обе оси — только A и B, ячейки и итоги не учитывают C; ID вне периметра — 422 `ENTITY_NOT_IN_PERIMETER`
#### Scenario: Пустая одна сторона
- **WHEN** `debit_account_ids = [62]`, `credit_account_ids` пуст
- **THEN** дебет — счёт 62 с потомками, кредит — все балансовые счета вида `P`/`AP`; режим выгрузки и аудита — `MANUAL`
### Requirement: XLSX шахматки
`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX
по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет
`legal_entity_ids` из запроса и использует
`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`.
Ответ — XLSX с именем
`intercompany-matrix_<ГК>_<год>-<квартал>_v<n>.xlsx`, шапкой (ГК, период, версия, единицы, режим,
выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для
каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API.
Справа от колонок компаний SHALL быть колонка «Итого Дт» с `total_debit` по каждому ЮЛ строки (ячейка объединена
на строки пары); под матрицей SHALL быть строка «Итого Кт» с `total_credit` по каждому ЮЛ колонки; ячейка на
пересечении пустая. Значения — те же, что в JSON.
#### Scenario: Экспорт выбранных счетов
- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования
#### Scenario: Подсветка пары без встречной стороны
- **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0`
- **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета»

View File

@ -0,0 +1,25 @@
## MODIFIED Requirements
### 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` — сумма по компаниям.
`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP`
(например, `"0.00"`).
Итоговой строки по всем статьям (по компании или по группе) в ответе нет: суммы разных статей не складываются
(решение аналитика 03.11.2026, согласовано с заказчиком). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
#### Scenario: Компания без ОСВ
- **WHEN** в периметре есть компания без подходящих строк ОСВ
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"`
#### Scenario: Нет обязательного счёта
- **WHEN** в плане счетов отсутствует счёт 58.03
- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03`
#### Scenario: Нет итога по статьям
- **WHEN** запрашивается результирующая таблица
- **THEN** в ответе нет `grand_total` и итогов по компании через все статьи; в XLSX под статьями нет строки «Итого»

View File

@ -0,0 +1,12 @@
# Tasks
## 1. Бэк (PR arm-core #218, автор — Потапова)
- [x] 1.1 Единый маршрут матрицы и выгрузки, `legal_entity_ids`, `ENTITY_NOT_IN_PERIMETER`
- [x] 1.2 `total_debit` / `total_credit` в ответе и XLSX
- [x] 1.3 Аудит выгрузки с `legal_entity_ids`
- [x] 1.4 Ревью: `grand_total` убран, переименование `total_debit`, квантование итогов, change вместо правки `specs/`
## 2. Фронт (AURORA-1757, отдельный PR)
- [ ] 2.1 Клиент на единый маршрут, итоги из API, фильтр «Компании» в запрос