Merge pull request 'AURORA-1756' (#218) from AURORA-1756 into main
This commit is contained in:
commit
593456d6e4
@ -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` — она фиксируется при создании версии консолидации (действующая на дату
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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": []}),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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"},
|
||||||
|
|||||||
18
openspec/changes/intercompany-matrix-totals/design.md
Normal file
18
openspec/changes/intercompany-matrix-totals/design.md
Normal 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 итоговой строки нет | если заказчику понадобится итог — отдельная задача с правилом |
|
||||||
31
openspec/changes/intercompany-matrix-totals/proposal.md
Normal file
31
openspec/changes/intercompany-matrix-totals/proposal.md
Normal 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 не меняется.
|
||||||
@ -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`, пустые массивы счетов и число ячеек
|
||||||
@ -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** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета»
|
||||||
@ -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 под статьями нет строки «Итого»
|
||||||
12
openspec/changes/intercompany-matrix-totals/tasks.md
Normal file
12
openspec/changes/intercompany-matrix-totals/tasks.md
Normal 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, фильтр «Компании» в запрос
|
||||||
Loading…
x
Reference in New Issue
Block a user