Merge pull request 'add-preset-shahmatka' (#141) from add-preset-shahmatka into main
This commit is contained in:
commit
96f2b9a6d9
12
README.md
12
README.md
@ -871,8 +871,16 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
|
||||
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
|
||||
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
|
||||
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
|
||||
(аудит `EXPORT`, `report = "intercompany_result_table"`). Контракт для фронта —
|
||||
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
|
||||
(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации):
|
||||
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
|
||||
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
|
||||
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
|
||||
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
|
||||
`_rule-balance-<rule_no>.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель»,
|
||||
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический
|
||||
`rule_version_no`. Открытые вопросы по пресетам —
|
||||
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —
|
||||
`../docs/dev/frontend-api-guide.md` §5.7.1 (docs #146); требования — в существующих спецификациях
|
||||
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
|
||||
Расчёт читает сводную ОСВ версии, результат не хранится.
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.consolidations import EliminationRuleServiceDep
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.consolidations import EliminationRuleOut
|
||||
from src.schemas.consolidations import EliminationRulesOut
|
||||
|
||||
router = APIRouter(prefix="/consolidations/versions")
|
||||
|
||||
@ -20,7 +20,7 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/elimination-rules",
|
||||
response_model=list[EliminationRuleOut],
|
||||
response_model=EliminationRulesOut,
|
||||
dependencies=VIEW,
|
||||
responses=NOT_FOUND,
|
||||
summary="Правила для шахматки",
|
||||
@ -28,6 +28,6 @@ NOT_FOUND: dict[int | str, dict[str, Any]] = {
|
||||
async def list_rules(
|
||||
version_id: uuid.UUID,
|
||||
service: EliminationRuleServiceDep,
|
||||
) -> list[EliminationRuleOut]:
|
||||
"""Все правила выбранной версии со счетами и исключениями обеих сторон."""
|
||||
) -> EliminationRulesOut:
|
||||
"""Правила §4 зафиксированной либо последней открытой версии с основанием выбора."""
|
||||
return await service.list_rules(version_id)
|
||||
|
||||
@ -12,7 +12,11 @@ from src.api.dependencies.consolidations import IntercompanyMatrixServiceDep
|
||||
from src.common.filenames import content_disposition
|
||||
from src.models.enums import AuditAction, AuditSection
|
||||
from src.schemas.common import ErrorDetail
|
||||
from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||
from src.schemas.consolidations import (
|
||||
IntercompanyMatrixManualIn,
|
||||
IntercompanyMatrixOut,
|
||||
IntercompanyResultTableOut,
|
||||
)
|
||||
from src.services.intercompany_matrix import MatrixExportInfo
|
||||
from src.services.intercompany_matrix_export import build_matrix_workbook, build_result_table_workbook
|
||||
|
||||
@ -55,6 +59,8 @@ async def _export_response(
|
||||
selection: IntercompanyMatrixManualIn | None = None,
|
||||
) -> Response:
|
||||
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):
|
||||
raise ValueError("Для аудита PRESET нужны ID, номер и версия правила")
|
||||
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx")
|
||||
await audit.record(
|
||||
action=AuditAction.EXPORT,
|
||||
@ -67,6 +73,15 @@ async def _export_response(
|
||||
"mode": info.mode,
|
||||
"debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [],
|
||||
"credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [],
|
||||
**(
|
||||
{
|
||||
"rule_id": str(info.rule_id),
|
||||
"rule_no": info.rule_no,
|
||||
"rule_version_no": info.rule_version_no,
|
||||
}
|
||||
if info.mode == "PRESET"
|
||||
else {}
|
||||
),
|
||||
"cells": len(matrix.cells),
|
||||
},
|
||||
)
|
||||
@ -153,6 +168,40 @@ async def export_intercompany_matrix_manual(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/intercompany-matrix/preset/{rule_id}",
|
||||
response_model=IntercompanyMatrixOut,
|
||||
dependencies=VIEW,
|
||||
responses=ERRORS,
|
||||
summary="Рассчитать шахматку по пресету",
|
||||
)
|
||||
async def build_intercompany_matrix_preset(
|
||||
version_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
service: IntercompanyMatrixServiceDep,
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""Шахматка §6 по счетам сторон правила §4."""
|
||||
return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/intercompany-matrix/preset/{rule_id}/export",
|
||||
response_class=Response,
|
||||
dependencies=VIEW,
|
||||
responses=EXPORT_RESPONSES,
|
||||
summary="Скачать шахматку по пресету в XLSX",
|
||||
)
|
||||
async def export_intercompany_matrix_preset(
|
||||
version_id: uuid.UUID,
|
||||
rule_id: uuid.UUID,
|
||||
service: IntercompanyMatrixServiceDep,
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
) -> Response:
|
||||
"""XLSX шахматки §6 по правилу §4 с аудитом выгрузки."""
|
||||
matrix, info = await service.build_export(version_id, mode="PRESET", rule_id=rule_id)
|
||||
return await _export_response(version_id, matrix, info, audit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{version_id}/intercompany-matrix/result-table",
|
||||
response_model=IntercompanyResultTableOut,
|
||||
|
||||
@ -3,22 +3,28 @@
|
||||
import uuid
|
||||
from collections.abc import Collection
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
|
||||
from src.models.consolidation import ConsolidationVersion
|
||||
|
||||
|
||||
async def list_rules_with_accounts(
|
||||
session: AsyncSession, consolidation_version_id: uuid.UUID
|
||||
session: AsyncSession, version_no: int | None
|
||||
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
|
||||
"""Правила версии консолидации по `elimination_rules_version` и счета их сторон."""
|
||||
"""Правила §4 заданной либо последней открытой версии."""
|
||||
condition = (
|
||||
EliminationRule.version_no == version_no
|
||||
if version_no is not None
|
||||
else (
|
||||
EliminationRule.valid_to.is_(None)
|
||||
& (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery())
|
||||
)
|
||||
)
|
||||
stmt = (
|
||||
select(EliminationRule, EliminationRuleAccount)
|
||||
.join(ConsolidationVersion, ConsolidationVersion.elimination_rules_version == EliminationRule.version_no)
|
||||
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
|
||||
.where(ConsolidationVersion.id == consolidation_version_id)
|
||||
.where(condition)
|
||||
.order_by(
|
||||
EliminationRule.statement_type,
|
||||
EliminationRule.rule_no,
|
||||
@ -28,6 +34,21 @@ async def list_rules_with_accounts(
|
||||
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
def _current_open_version_stmt():
|
||||
"""Запрос номера последней открытой версии правил §4."""
|
||||
return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None))
|
||||
|
||||
|
||||
async def current_open_version_no(session: AsyncSession) -> int | None:
|
||||
"""Номер последней версии с открытыми правилами §4."""
|
||||
return await session.scalar(_current_open_version_stmt())
|
||||
|
||||
|
||||
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
|
||||
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
|
||||
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)
|
||||
|
||||
|
||||
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:
|
||||
"""Все правила заданной версии, включая ББ и ОФР, в порядке типа отчётности и номера."""
|
||||
stmt = (
|
||||
@ -51,3 +72,19 @@ async def list_rule_accounts(
|
||||
.order_by(EliminationRuleAccount.rule_id, EliminationRuleAccount.company_no, ChartOfAccount.code)
|
||||
)
|
||||
return [(account, code) for account, code in (await session.execute(stmt)).all()]
|
||||
|
||||
|
||||
async def rule_with_account(
|
||||
session: AsyncSession, rule_id: uuid.UUID
|
||||
) -> tuple[EliminationRule | None, list[EliminationRuleAccount]]:
|
||||
"""Правило §4 и его счета; `company_no` задаёт сторону, `is_excluded` — исключение."""
|
||||
rule = await session.get(EliminationRule, rule_id)
|
||||
if rule is None:
|
||||
return None, []
|
||||
stmt = (
|
||||
select(EliminationRuleAccount)
|
||||
.join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id)
|
||||
.where(EliminationRuleAccount.rule_id == rule_id)
|
||||
.order_by(EliminationRuleAccount.company_no, ChartOfAccount.code)
|
||||
)
|
||||
return rule, list((await session.scalars(stmt)).all())
|
||||
|
||||
@ -318,6 +318,7 @@ class IntercompanyMatrixRuleSideOut(BaseModel):
|
||||
class EliminationRuleOut(BaseModel):
|
||||
"""Правило элиминации из БД; ключ — тип отчётности, версия и номер."""
|
||||
|
||||
id: uuid.UUID
|
||||
rule_no: int
|
||||
version_no: int
|
||||
statement_type: StatementType
|
||||
@ -327,6 +328,14 @@ class EliminationRuleOut(BaseModel):
|
||||
company_2: IntercompanyMatrixRuleSideOut
|
||||
|
||||
|
||||
class EliminationRulesOut(BaseModel):
|
||||
"""Правила и основание выбора их версии."""
|
||||
|
||||
version_no: int | None
|
||||
is_version_fixed: bool
|
||||
rules: list[EliminationRuleOut]
|
||||
|
||||
|
||||
class IntercompanyMatrixManualIn(BaseModel):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
import uuid
|
||||
from collections.abc import Collection, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
@ -109,7 +110,7 @@ def _source_row(entry: OsvEntry) -> domain.SourceRow:
|
||||
|
||||
|
||||
def _candidate_ids(rows: Sequence[domain.SourceRow], index: CounterpartyIndex) -> set[uuid.UUID]:
|
||||
"""Идентификаторы контрагентов, имена которых понадобятся `build`: хранимые `counterparty_id` и результат дорезолва
|
||||
"""Идентификаторы контрагентов для `build_with_members`: хранимые `counterparty_id` и результат дорезолва
|
||||
для строк без него (`resolve` — по словарям индекса, без обращений к БД; ключ склейки включает имя и ИНН, поэтому
|
||||
результат для представителя группы равен результату для любой её строки)."""
|
||||
ids: set[uuid.UUID] = set()
|
||||
@ -129,6 +130,16 @@ async def _entities(session: AsyncSession, ids: Collection[uuid.UUID]) -> dict[u
|
||||
return {entity.id: _entity(entity) for entity in found.scalars()}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsolidatedOsvBuildResult:
|
||||
"""Свод и данные версии, загруженные для его расчёта."""
|
||||
|
||||
consolidated: domain.Consolidated
|
||||
version: ConsolidationVersion
|
||||
consolidation: Consolidation
|
||||
members: dict[uuid.UUID, domain.Entity]
|
||||
|
||||
|
||||
class ConsolidatedOsvService:
|
||||
"""Чтение сводной ОСВ версии: страница с фильтрами и сводка с пробелами (без блокировок и аудита)."""
|
||||
|
||||
@ -136,15 +147,8 @@ class ConsolidatedOsvService:
|
||||
self._session = session
|
||||
self._scope = scope
|
||||
|
||||
async def build(self, version_id: uuid.UUID) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation]:
|
||||
"""Свод версии (`domain.build`) вместе с версией и консолидацией; версия вне видимости — 404."""
|
||||
consolidated, version, consolidation, _ = await self.build_with_members(version_id)
|
||||
return consolidated, version, consolidation
|
||||
|
||||
async def build_with_members(
|
||||
self, version_id: uuid.UUID
|
||||
) -> tuple[domain.Consolidated, ConsolidationVersion, Consolidation, dict[uuid.UUID, domain.Entity]]:
|
||||
"""Свод и участники периметра, загруженные при его расчёте."""
|
||||
async def build_with_members(self, version_id: uuid.UUID) -> ConsolidatedOsvBuildResult:
|
||||
"""Свод и участники периметра, загруженные при его расчёте; версия вне видимости — 404."""
|
||||
found = await repo.get_version(self._session, self._scope, version_id)
|
||||
if found is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
@ -174,12 +178,17 @@ class ConsolidatedOsvService:
|
||||
rows=rows,
|
||||
resolver=index,
|
||||
)
|
||||
return consolidated, version, consolidation, members
|
||||
return ConsolidatedOsvBuildResult(
|
||||
consolidated=consolidated,
|
||||
version=version,
|
||||
consolidation=consolidation,
|
||||
members=members,
|
||||
)
|
||||
|
||||
async def page(self, version_id: uuid.UUID, f: OsvFilters, page: OsvPageParams) -> Page[ConsolidatedRowOut]:
|
||||
"""Страница свода после фильтров: `total` — число строк после фильтра, срез — в памяти (порядок D-16)."""
|
||||
consolidated, _, _ = await self.build(version_id)
|
||||
rows = domain.apply_filter(consolidated.rows, domain_filter(f))
|
||||
result = await self.build_with_members(version_id)
|
||||
rows = domain.apply_filter(result.consolidated.rows, domain_filter(f))
|
||||
window = rows[page.offset : page.offset + page.limit]
|
||||
return Page[ConsolidatedRowOut](
|
||||
items=[row_out(row) for row in window], total=len(rows), limit=page.limit, offset=page.offset
|
||||
@ -189,48 +198,47 @@ class ConsolidatedOsvService:
|
||||
"""xlsx сводной ОСВ (spec «Экспорт xlsx», D-14): строки после тех же фильтров, что у `page`, без пагинации, в
|
||||
порядке D-16; лист пробелов — по своду целиком. Имя файла — `svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx` (без
|
||||
percent-кодирования: оно в маршруте). `audit_event(EXPORT)` пишется после сборки книги, в транзакции запроса."""
|
||||
consolidated, version, consolidation = await self.build(version_id)
|
||||
rows = domain.apply_filter(consolidated.rows, domain_filter(f))
|
||||
content = await run_in_threadpool(build_workbook, consolidated, rows)
|
||||
result = await self.build_with_members(version_id)
|
||||
rows = domain.apply_filter(result.consolidated.rows, domain_filter(f))
|
||||
content = await run_in_threadpool(build_workbook, result.consolidated, rows)
|
||||
group_name = (
|
||||
await self._session.execute(
|
||||
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
|
||||
select(CompanyGroup.name).where(CompanyGroup.id == result.consolidation.company_group_id)
|
||||
)
|
||||
).scalar_one()
|
||||
await audit.record(
|
||||
action=AuditAction.EXPORT,
|
||||
section=AuditSection.EXPORT,
|
||||
object_type="consolidation_version",
|
||||
object_id=version.id,
|
||||
consolidation_version_id=version.id,
|
||||
object_id=result.version.id,
|
||||
consolidation_version_id=result.version.id,
|
||||
after_value={
|
||||
"report": "consolidated_osv",
|
||||
"filters": f.model_dump(mode="json", exclude_none=True),
|
||||
"rows": len(rows),
|
||||
},
|
||||
)
|
||||
filename = (
|
||||
f"svod-osv_{group_name}_{consolidation.year}-{consolidation.quarter.value}_v{version.version_no}.xlsx"
|
||||
)
|
||||
period = f"{result.consolidation.year}-{result.consolidation.quarter.value}"
|
||||
filename = f"svod-osv_{group_name}_{period}_v{result.version.version_no}.xlsx"
|
||||
return content, filename
|
||||
|
||||
async def summary(self, version_id: uuid.UUID) -> OsvSummaryOut:
|
||||
"""Сводка: строки, несопоставленные, автосопоставленные по имени/алиасу, ОСВ-документы версии и пробелы."""
|
||||
consolidated, _, _ = await self.build(version_id)
|
||||
rows_total, unmapped_total, auto_matched_total = domain.summary(consolidated)
|
||||
result = await self.build_with_members(version_id)
|
||||
rows_total, unmapped_total, auto_matched_total = domain.summary(result.consolidated)
|
||||
return OsvSummaryOut(
|
||||
rows_total=rows_total,
|
||||
unmapped_total=unmapped_total,
|
||||
auto_matched_total=auto_matched_total,
|
||||
documents_total=consolidated.documents_total,
|
||||
gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in consolidated.gaps],
|
||||
documents_total=result.consolidated.documents_total,
|
||||
gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in result.consolidated.gaps],
|
||||
)
|
||||
|
||||
async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None:
|
||||
"""Строка свода, в `entry_ids` которой есть `entry_id`; `None` — строка в своде не участвует (пара с пробелом
|
||||
или документ не выбран источником при наличии нарастающего)."""
|
||||
consolidated, _, _ = await self.build(version_id)
|
||||
row = next((row for row in consolidated.rows if entry_id in row.entry_ids), None)
|
||||
result = await self.build_with_members(version_id)
|
||||
row = next((row for row in result.consolidated.rows if entry_id in row.entry_ids), None)
|
||||
return None if row is None else row_out(row)
|
||||
|
||||
async def candidates(
|
||||
|
||||
@ -8,7 +8,7 @@ from src.common.exceptions import NotFoundError
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.repositories import consolidations as consolidations_repo
|
||||
from src.repositories import elimination_rules as rules_repo
|
||||
from src.schemas.consolidations import EliminationRuleOut, IntercompanyMatrixRuleSideOut
|
||||
from src.schemas.consolidations import EliminationRuleOut, EliminationRulesOut, IntercompanyMatrixRuleSideOut
|
||||
|
||||
VERSION_NOT_FOUND = "версия консолидации не найдена"
|
||||
|
||||
@ -20,18 +20,24 @@ class EliminationRuleService:
|
||||
self._session = session
|
||||
self._scope = scope
|
||||
|
||||
async def list_rules(self, consolidation_version_id: uuid.UUID) -> list[EliminationRuleOut]:
|
||||
async def list_rules(self, consolidation_version_id: uuid.UUID) -> EliminationRulesOut:
|
||||
"""Правила §4 версии консолидации с проверкой видимости и основанием выбора."""
|
||||
found = await consolidations_repo.get_version(self._session, self._scope, consolidation_version_id)
|
||||
if found is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
|
||||
rows = await rules_repo.list_rules_with_accounts(self._session, consolidation_version_id)
|
||||
version, _ = found
|
||||
is_version_fixed = version.elimination_rules_version is not None
|
||||
version_no = version.elimination_rules_version
|
||||
rows = await rules_repo.list_rules_with_accounts(self._session, version_no)
|
||||
if not is_version_fixed and rows:
|
||||
version_no = rows[0][0].version_no
|
||||
by_id: dict[uuid.UUID, EliminationRuleOut] = {}
|
||||
for rule, account in rows:
|
||||
result = by_id.get(rule.id)
|
||||
if result is None:
|
||||
result = EliminationRuleOut.model_validate(
|
||||
{
|
||||
"id": rule.id,
|
||||
"rule_no": rule.rule_no,
|
||||
"version_no": rule.version_no,
|
||||
"statement_type": rule.statement_type,
|
||||
@ -48,4 +54,8 @@ class EliminationRuleService:
|
||||
side = result.company_1 if account.company_no == 1 else result.company_2
|
||||
(side.excluded if account.is_excluded else side.accounts).append(account.account_id)
|
||||
|
||||
return list(by_id.values())
|
||||
return EliminationRulesOut(
|
||||
version_no=version_no,
|
||||
is_version_fixed=is_version_fixed,
|
||||
rules=list(by_id.values()),
|
||||
)
|
||||
|
||||
@ -10,14 +10,15 @@ from typing import Literal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.common.exceptions import DomainValidationError
|
||||
from src.common.exceptions import DomainValidationError, NotFoundError
|
||||
from src.domain.consolidated_osv import ConsolidatedRow, Entity
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule
|
||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||
from src.models.directories import CompanyGroup
|
||||
from src.models.enums import AccountKind, PeriodQuarter
|
||||
from src.models.enums import AccountKind, PeriodQuarter, StatementType
|
||||
from src.repositories import directories as directory_repo
|
||||
from src.repositories import elimination_rules as elimination_rules_repo
|
||||
from src.schemas.consolidations import (
|
||||
IntercompanyMatrixCellOut,
|
||||
IntercompanyMatrixCompanyOut,
|
||||
@ -75,6 +76,18 @@ class MatrixSelection:
|
||||
measure: Measure
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixBuildResult:
|
||||
"""Матрица и данные свода, использованные при её построении."""
|
||||
|
||||
matrix: IntercompanyMatrixOut
|
||||
version: ConsolidationVersion
|
||||
consolidation: Consolidation
|
||||
accounts: list[ChartOfAccount]
|
||||
selection: MatrixSelection
|
||||
rule: EliminationRule | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VersionExportInfo:
|
||||
"""Реквизиты версии в шапке и имени книги."""
|
||||
@ -90,13 +103,41 @@ class VersionExportInfo:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatrixExportInfo(VersionExportInfo):
|
||||
mode: Literal["ALL", "MANUAL"]
|
||||
mode: Literal["ALL", "MANUAL", "PRESET"]
|
||||
debit_account_codes: tuple[str, ...]
|
||||
credit_account_codes: tuple[str, ...]
|
||||
measure: Measure = "closing"
|
||||
rule_id: uuid.UUID | None = None
|
||||
rule_no: int | None = None
|
||||
rule_version_no: int | None = None
|
||||
rule_statement_type: StatementType | None = None
|
||||
article_1_line: str | None = None
|
||||
article_2_line: str | None = None
|
||||
|
||||
@property
|
||||
def preset_details(self) -> tuple[StatementType, int, int, str, str]:
|
||||
"""Реквизиты правила §4 для имени и шапки XLSX шахматки §6."""
|
||||
details = (
|
||||
self.rule_statement_type,
|
||||
self.rule_no,
|
||||
self.rule_version_no,
|
||||
self.article_1_line,
|
||||
self.article_2_line,
|
||||
)
|
||||
if any(value is None for value in details):
|
||||
raise ValueError("Для выгрузки PRESET нужны тип, номер, версия и статьи правила")
|
||||
statement_type, rule_no, rule_version_no, article_1_line, article_2_line = details
|
||||
assert statement_type is not None and rule_no is not None and rule_version_no is not None
|
||||
assert article_1_line is not None and article_2_line is not None
|
||||
return statement_type, rule_no, rule_version_no, article_1_line, article_2_line
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return self._filename("intercompany-matrix")
|
||||
preset = ""
|
||||
if self.mode == "PRESET":
|
||||
statement_type, rule_no, _, _, _ = self.preset_details
|
||||
preset = f"_rule-{statement_type.value}-{rule_no}"
|
||||
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -113,7 +154,7 @@ class ResultTableBuild:
|
||||
consolidation: Consolidation
|
||||
|
||||
|
||||
def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||||
def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||||
if measure == "closing":
|
||||
value = row.closing_debit if side == "D" else row.closing_credit
|
||||
else:
|
||||
@ -152,11 +193,11 @@ def calculate_matrix(
|
||||
continue
|
||||
if row.account_id in selection.company_1_account_ids:
|
||||
item = amounts_1[pair]
|
||||
item["amount"] += _amount(row, selection.measure, "D")
|
||||
item["amount"] += _get_amount(row, selection.measure, "D")
|
||||
item["entry_osv_ids"].update(row.entry_ids)
|
||||
if row.account_id in selection.company_2_account_ids:
|
||||
item = amounts_2[pair]
|
||||
item["amount"] += _amount(row, selection.measure, "K")
|
||||
item["amount"] += _get_amount(row, selection.measure, "K")
|
||||
item["entry_osv_ids"].update(row.entry_ids)
|
||||
|
||||
cells: list[IntercompanyMatrixCellOut] = []
|
||||
@ -195,7 +236,7 @@ def calculate_matrix(
|
||||
return IntercompanyMatrixOut(companies=list(company_info), cells=cells)
|
||||
|
||||
|
||||
def _account_tree(
|
||||
def _make_account_tree(
|
||||
accounts: Sequence[ChartOfAccount],
|
||||
) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]:
|
||||
by_id = {account.id: account for account in accounts}
|
||||
@ -218,11 +259,11 @@ def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID
|
||||
return result
|
||||
|
||||
|
||||
def _kinds(side: Side) -> frozenset[AccountKind]:
|
||||
def _get_kinds(side: Side) -> frozenset[AccountKind]:
|
||||
return DEBIT_KINDS if side == "D" else CREDIT_KINDS
|
||||
|
||||
|
||||
def _selected_accounts(
|
||||
def _select_accounts(
|
||||
included: Iterable[uuid.UUID],
|
||||
excluded: Iterable[uuid.UUID],
|
||||
side: Side,
|
||||
@ -234,10 +275,10 @@ def _selected_accounts(
|
||||
if missing:
|
||||
missing_ids = ", ".join(map(str, sorted(missing)))
|
||||
raise DomainValidationError("ACCOUNT_NOT_FOUND", f"Выбранного счёта нет ({missing_ids})")
|
||||
if any(by_id[account_id].kind not in _kinds(side) for account_id in roots):
|
||||
if any(by_id[account_id].kind not in _get_kinds(side) for account_id in roots):
|
||||
raise DomainValidationError("ACCOUNT_KIND_MISMATCH", "Сторона счёта не соответствует направлению расчёта")
|
||||
selected = _expand(roots, children) - _expand(excluded, children)
|
||||
return frozenset(account_id for account_id in selected if by_id[account_id].kind in _kinds(side))
|
||||
return frozenset(account_id for account_id in selected if by_id[account_id].kind in _get_kinds(side))
|
||||
|
||||
|
||||
def calculate_result_table(
|
||||
@ -246,7 +287,7 @@ def calculate_result_table(
|
||||
accounts: Sequence[ChartOfAccount],
|
||||
) -> IntercompanyResultTableOut:
|
||||
"""Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ."""
|
||||
by_id, children = _account_tree(accounts)
|
||||
by_id, children = _make_account_tree(accounts)
|
||||
by_code = {account.code: account.id for account in accounts}
|
||||
required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)}
|
||||
missing = sorted(required_codes - by_code.keys())
|
||||
@ -254,7 +295,7 @@ def calculate_result_table(
|
||||
raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}")
|
||||
|
||||
selected = {
|
||||
article.code: _selected_accounts(
|
||||
article.code: _select_accounts(
|
||||
(by_code[code] for code in article.account_codes),
|
||||
(by_code[code] for code in article.excluded_codes),
|
||||
article.side,
|
||||
@ -278,7 +319,7 @@ def calculate_result_table(
|
||||
continue
|
||||
for article in RESULT_ARTICLES:
|
||||
if row.account_id in selected[article.code]:
|
||||
amounts[article.code][row.legal_entity_id] += _amount(row, "closing", article.side)
|
||||
amounts[article.code][row.legal_entity_id] += _get_amount(row, "closing", article.side)
|
||||
|
||||
articles: list[IntercompanyResultArticleOut] = []
|
||||
for article in RESULT_ARTICLES:
|
||||
@ -311,8 +352,8 @@ class IntercompanyMatrixService:
|
||||
selection: MatrixSelection,
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом."""
|
||||
consolidated, _, _, members = await self._osv_service.build_with_members(version_id)
|
||||
return self._calculate(consolidated.rows, members, selection)
|
||||
result = await self._osv_service.build_with_members(version_id)
|
||||
return self._calculate(result.consolidated.rows, result.members, selection)
|
||||
|
||||
def _calculate(
|
||||
self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection
|
||||
@ -346,73 +387,157 @@ class IntercompanyMatrixService:
|
||||
).scalar_one()
|
||||
|
||||
async def _build_result_table(self, version_id: uuid.UUID) -> ResultTableBuild:
|
||||
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
|
||||
result = await self._osv_service.build_with_members(version_id)
|
||||
accounts = await directory_repo.list_accounts(self._session)
|
||||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
|
||||
result = calculate_result_table(consolidated.rows, companies, accounts)
|
||||
return ResultTableBuild(result, version, consolidation)
|
||||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in result.members.values()]
|
||||
table = calculate_result_table(result.consolidated.rows, companies, accounts)
|
||||
return ResultTableBuild(table, result.version, result.consolidation)
|
||||
|
||||
async def build_accounts(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
mode: Literal["ALL", "MANUAL"],
|
||||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||||
debit_account_ids: Sequence[uuid.UUID] = (),
|
||||
credit_account_ids: Sequence[uuid.UUID] = (),
|
||||
rule_id: uuid.UUID | None = None,
|
||||
) -> IntercompanyMatrixOut:
|
||||
"""ALL использует все счета нужного вида; MANUAL раскрывает выбранные счета и потомков."""
|
||||
matrix, _, _, _ = await self._build_accounts(
|
||||
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
|
||||
"""Построить шахматку по выбранному режиму.
|
||||
|
||||
ALL включает все счета соответствующей стороны; MANUAL — выбранные счета и их потомков;
|
||||
PRESET берёт счета и показатель из правила с идентификатором `rule_id`.
|
||||
"""
|
||||
result = await self._build_accounts(
|
||||
version_id,
|
||||
mode=mode,
|
||||
debit_account_ids=debit_account_ids,
|
||||
credit_account_ids=credit_account_ids,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
return matrix
|
||||
return result.matrix
|
||||
|
||||
async def build_export(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
mode: Literal["ALL", "MANUAL"],
|
||||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||||
debit_account_ids: Sequence[uuid.UUID] = (),
|
||||
credit_account_ids: Sequence[uuid.UUID] = (),
|
||||
rule_id: uuid.UUID | None = None,
|
||||
) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
|
||||
"""Матрица и реквизиты книги из одного расчёта свода."""
|
||||
matrix, version, consolidation, accounts = await self._build_accounts(
|
||||
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
|
||||
)
|
||||
by_id = {account.id: account.code for account in accounts}
|
||||
info = MatrixExportInfo(
|
||||
group_name=await self._group_name(consolidation),
|
||||
year=consolidation.year,
|
||||
quarter=consolidation.quarter,
|
||||
version_no=version.version_no,
|
||||
result = await self._build_accounts(
|
||||
version_id,
|
||||
mode=mode,
|
||||
debit_account_codes=tuple(by_id[account_id] for account_id in debit_account_ids),
|
||||
credit_account_codes=tuple(by_id[account_id] for account_id in credit_account_ids),
|
||||
debit_account_ids=debit_account_ids,
|
||||
credit_account_ids=credit_account_ids,
|
||||
rule_id=rule_id,
|
||||
)
|
||||
return matrix, info
|
||||
group_name = await self._group_name(result.consolidation)
|
||||
by_id = {account.id: account.code for account in result.accounts}
|
||||
if mode == "PRESET":
|
||||
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))
|
||||
else:
|
||||
debit_codes = tuple(by_id[account_id] for account_id in debit_account_ids)
|
||||
credit_codes = tuple(by_id[account_id] for account_id in credit_account_ids)
|
||||
info = MatrixExportInfo(
|
||||
group_name=group_name,
|
||||
year=result.consolidation.year,
|
||||
quarter=result.consolidation.quarter,
|
||||
version_no=result.version.version_no,
|
||||
mode=mode,
|
||||
debit_account_codes=debit_codes,
|
||||
credit_account_codes=credit_codes,
|
||||
measure=result.selection.measure,
|
||||
rule_id=result.rule.id if result.rule is not None else None,
|
||||
rule_no=result.rule.rule_no if result.rule is not None else None,
|
||||
rule_version_no=result.rule.version_no if result.rule is not None else None,
|
||||
rule_statement_type=result.rule.statement_type if result.rule is not None else None,
|
||||
article_1_line=result.rule.article_1_line if result.rule is not None else None,
|
||||
article_2_line=result.rule.article_2_line if result.rule is not None else None,
|
||||
)
|
||||
return result.matrix, info
|
||||
|
||||
async def _resolve_preset_selection(
|
||||
self,
|
||||
rule_id: uuid.UUID | None,
|
||||
version: ConsolidationVersion,
|
||||
by_id: dict[uuid.UUID, ChartOfAccount],
|
||||
children: dict[uuid.UUID, list[uuid.UUID]],
|
||||
) -> tuple[MatrixSelection, EliminationRule]:
|
||||
"""Выбрать стороны счетов и показатель правила §4 для шахматки §6."""
|
||||
if rule_id is None:
|
||||
raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет")
|
||||
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id)
|
||||
if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
|
||||
raise NotFoundError("правило-пресет не найдено для версии консолидации")
|
||||
if version.elimination_rules_version is None:
|
||||
open_version_no = await elimination_rules_repo.current_open_version_no(self._session)
|
||||
if rule.version_no != open_version_no:
|
||||
raise NotFoundError("правило-пресет не найдено для версии консолидации")
|
||||
if rule.statement_type != StatementType.balance:
|
||||
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
|
||||
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):
|
||||
raise DomainValidationError("INVALID_RULE", "Неверное направление или показатель правила-пресета")
|
||||
|
||||
sides: dict[int, tuple[list[uuid.UUID], list[uuid.UUID]]] = {
|
||||
1: ([], []),
|
||||
2: ([], []),
|
||||
}
|
||||
for rule_account in rule_accounts:
|
||||
if rule_account.company_no not in sides:
|
||||
raise DomainValidationError("INVALID_RULE", "Неверная сторона счёта в правиле-пресете")
|
||||
included, excluded = sides[rule_account.company_no]
|
||||
(excluded if rule_account.is_excluded else included).append(rule_account.account_id)
|
||||
debit_side = 1 if rule.side_1 == "D" else 2
|
||||
credit_side = 2 if debit_side == 1 else 1
|
||||
debit_ids, debit_excluded = sides[debit_side]
|
||||
credit_ids, credit_excluded = sides[credit_side]
|
||||
if not debit_ids or not credit_ids:
|
||||
raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон")
|
||||
debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children)
|
||||
credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children)
|
||||
measure: Measure = "closing" if rule.measure == "closing" else "turnover"
|
||||
|
||||
return MatrixSelection(debit, credit, measure), rule
|
||||
|
||||
async def _build_accounts(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
*,
|
||||
mode: Literal["ALL", "MANUAL"],
|
||||
mode: Literal["ALL", "MANUAL", "PRESET"],
|
||||
debit_account_ids: Sequence[uuid.UUID],
|
||||
credit_account_ids: Sequence[uuid.UUID],
|
||||
) -> tuple[IntercompanyMatrixOut, ConsolidationVersion, Consolidation, list[ChartOfAccount]]:
|
||||
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
|
||||
rule_id: uuid.UUID | None = None,
|
||||
) -> MatrixBuildResult:
|
||||
osv_result = await self._osv_service.build_with_members(version_id)
|
||||
rows = osv_result.consolidated.rows
|
||||
accounts = await directory_repo.list_accounts(self._session)
|
||||
by_id, children = _account_tree(accounts)
|
||||
by_id, children = _make_account_tree(accounts)
|
||||
rule: EliminationRule | None = None
|
||||
if mode == "ALL":
|
||||
if debit_account_ids or credit_account_ids:
|
||||
raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета")
|
||||
debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS)
|
||||
credit = frozenset(account.id for account in accounts if 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 = _selected_accounts(debit_account_ids, (), "D", by_id, children)
|
||||
credit = _selected_accounts(credit_account_ids, (), "K", by_id, children)
|
||||
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)
|
||||
else:
|
||||
raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов")
|
||||
selection = MatrixSelection(debit, credit, "closing")
|
||||
matrix = self._calculate(consolidated.rows, members, selection)
|
||||
return matrix, version, consolidation, accounts
|
||||
matrix = self._calculate(rows, osv_result.members, selection)
|
||||
return MatrixBuildResult(
|
||||
matrix=matrix,
|
||||
version=osv_result.version,
|
||||
consolidation=osv_result.consolidation,
|
||||
accounts=accounts,
|
||||
selection=selection,
|
||||
rule=rule,
|
||||
)
|
||||
|
||||
@ -12,8 +12,6 @@ from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExport
|
||||
from src.services.osv_export import MONEY_FORMAT
|
||||
|
||||
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
|
||||
TABLE_HEADER_ROW = 10
|
||||
DATA_START_ROW = TABLE_HEADER_ROW + 1
|
||||
PERCENT_FORMAT = '0.00"%"'
|
||||
THRESHOLD_FILL = PatternFill("solid", fgColor="FCE8E6")
|
||||
THRESHOLD_FONT = Font(color="9C0006", bold=True)
|
||||
@ -35,7 +33,7 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non
|
||||
|
||||
def _write_details(
|
||||
sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = ()
|
||||
) -> None:
|
||||
) -> int:
|
||||
"""Заголовок и строки «реквизит — значение» над таблицей книги."""
|
||||
_text(sheet, 1, 1, title)
|
||||
details = (
|
||||
@ -49,19 +47,34 @@ def _write_details(
|
||||
_text(sheet, row, 1, label)
|
||||
_text(sheet, row, 2, value)
|
||||
sheet.cell(row, 1).font = Font(bold=True)
|
||||
return len(details) + 2
|
||||
|
||||
|
||||
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
|
||||
_write_details(
|
||||
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int:
|
||||
mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode]
|
||||
extra = [
|
||||
("Режим", mode_name),
|
||||
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else "Все счета"),
|
||||
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else "Все счета"),
|
||||
("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"),
|
||||
]
|
||||
if info.mode == "PRESET":
|
||||
statement_type, rule_no, rule_version_no, article_1_line, article_2_line = info.preset_details
|
||||
rule_type = "ББ" if statement_type.value == "balance" else "ОФР"
|
||||
extra.extend(
|
||||
(
|
||||
("Правило", f"{rule_type} № {rule_no}"),
|
||||
("Статья А", article_1_line),
|
||||
("Статья Б", article_2_line),
|
||||
("Версия правил", f"v{rule_version_no}"),
|
||||
)
|
||||
)
|
||||
return _write_details(
|
||||
sheet,
|
||||
"Шахматка внутригрупповых операций",
|
||||
info,
|
||||
"руб.; расхождение — %",
|
||||
(
|
||||
("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"),
|
||||
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
||||
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
||||
),
|
||||
tuple(extra),
|
||||
)
|
||||
|
||||
|
||||
@ -78,24 +91,25 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
|
||||
sheet = workbook.active
|
||||
assert sheet is not None
|
||||
sheet.title = "Шахматка"
|
||||
_write_context(sheet, info)
|
||||
_text(sheet, TABLE_HEADER_ROW, 1, "Дебет ↓ / Кредит →")
|
||||
_text(sheet, TABLE_HEADER_ROW, 2, "Показатель")
|
||||
table_header_row = _write_context(sheet, info)
|
||||
data_start_row = table_header_row + 1
|
||||
_text(sheet, table_header_row, 1, "Дебет ↓ / Кредит →")
|
||||
_text(sheet, table_header_row, 2, "Показатель")
|
||||
sheet.column_dimensions["A"].width = 32
|
||||
sheet.column_dimensions["B"].width = 40
|
||||
|
||||
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.row_dimensions[TABLE_HEADER_ROW].height = 36
|
||||
for header in sheet[TABLE_HEADER_ROW]:
|
||||
sheet.row_dimensions[table_header_row].height = 36
|
||||
for header in sheet[table_header_row]:
|
||||
header.font = Font(bold=True)
|
||||
header.fill = HEADER_FILL
|
||||
header.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
cells = {(cell.company_a_id, cell.company_b_id): cell for cell in matrix.cells}
|
||||
for company_index, debit_company in enumerate(matrix.companies):
|
||||
start_row = DATA_START_ROW + company_index * len(METRICS)
|
||||
start_row = data_start_row + company_index * len(METRICS)
|
||||
end_row = start_row + len(METRICS) - 1
|
||||
sheet.merge_cells(start_row=start_row, start_column=1, end_row=end_row, end_column=1)
|
||||
_text(sheet, start_row, 1, debit_company.name)
|
||||
@ -126,7 +140,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
|
||||
marked.fill = THRESHOLD_FILL
|
||||
marked.font = THRESHOLD_FONT
|
||||
|
||||
sheet.freeze_panes = f"C{DATA_START_ROW}"
|
||||
sheet.freeze_panes = f"C{data_start_row}"
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
@ -13,11 +13,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import ChartOfAccount
|
||||
from src.models.accounting import ChartOfAccount, EliminationRule
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
|
||||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
|
||||
from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter
|
||||
from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind, PeriodQuarter, StatementType
|
||||
from src.models.osv import OsvEntry
|
||||
from src.repositories import consolidations as consolidation_repo
|
||||
from src.repositories import directories as directory_repo
|
||||
@ -27,6 +27,7 @@ from tests.factories import make_company_group, make_confirmed_osv, make_consoli
|
||||
pytestmark = pytest.mark.integration
|
||||
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
|
||||
KA_RF = "ka_rf@armka.local"
|
||||
RECEIVABLES_PAYABLES_RULE_NO = 6
|
||||
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export")
|
||||
|
||||
|
||||
@ -129,7 +130,11 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed
|
||||
assert response.status_code == OK, response.text
|
||||
assert len(response.json()["cells"]) == len(world.members)
|
||||
for cell in response.json()["cells"]:
|
||||
assert {"osv_entry_ids_debit", "osv_entry_ids_credit", "exceeds_threshold"} <= cell.keys()
|
||||
assert {
|
||||
"osv_entry_ids_debit",
|
||||
"osv_entry_ids_credit",
|
||||
"exceeds_threshold",
|
||||
} <= cell.keys()
|
||||
assert {"entry_osv_ids_debit", "entry_osv_ids_credit", "is_more_5_percent"}.isdisjoint(cell)
|
||||
assert cell["amount_a"] == cell["amount_b"] == cell["variance_amount"] == "0.00"
|
||||
assert cell["variance_percent"] is None
|
||||
@ -138,6 +143,127 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed
|
||||
assert cell["skipped_osv_entry_count"] == 0
|
||||
|
||||
|
||||
async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
world = await _world(seeded)
|
||||
rule = (
|
||||
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
|
||||
).one()
|
||||
world.version.elimination_rules_version = rule.version_no
|
||||
await seeded.flush()
|
||||
await _source(seeded, world, credit=False, amount="100", account="62.01")
|
||||
await _source(seeded, world, credit=True, amount="90", account="60.01")
|
||||
await _source(seeded, world, credit=False, amount="500", account="76.07.5")
|
||||
await _source(seeded, world, credit=True, amount="400", account="76.07.1")
|
||||
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.get(f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}")
|
||||
assert response.status_code == OK, response.text
|
||||
cell = next(item for item in response.json()["cells"] if item["company_a_id"] == str(world.members[0].id))
|
||||
assert cell["amount_a"] == "100.00"
|
||||
assert cell["amount_b"] == "90.00"
|
||||
|
||||
exported = await client.get(
|
||||
f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/export"
|
||||
)
|
||||
assert exported.status_code == OK, exported.text
|
||||
assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"])
|
||||
sheet = load_workbook(BytesIO(exported.content))["Шахматка"]
|
||||
assert sheet["B6"].value == "Пресет"
|
||||
assert "76.07.5" not in sheet["B7"].value
|
||||
assert "76.07.1" not in sheet["B8"].value
|
||||
assert sheet["B9"].value == "Сальдо"
|
||||
assert sheet["B10"].value == f"ББ № {rule.rule_no}"
|
||||
assert sheet["B11"].value == rule.article_1_line
|
||||
assert sheet["B12"].value == rule.article_2_line
|
||||
assert sheet["B13"].value == f"v{rule.version_no}"
|
||||
assert Decimal(str(sheet["D16"].value)) == Decimal("100")
|
||||
assert Decimal(str(sheet["D17"].value)) == Decimal("90")
|
||||
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||
assert audit.after_value["mode"] == "PRESET"
|
||||
assert audit.after_value["rule_id"] == str(rule.id)
|
||||
assert audit.after_value["rule_no"] == rule.rule_no
|
||||
assert audit.after_value["rule_version_no"] == rule.version_no
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", ["", "/export"])
|
||||
async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None:
|
||||
world = await _world(seeded)
|
||||
rule = (
|
||||
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
|
||||
).one()
|
||||
rule.statement_type = StatementType.income
|
||||
await seeded.flush()
|
||||
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.get(
|
||||
f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}{suffix}"
|
||||
)
|
||||
assert response.status_code == UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "INVALID_RULE"
|
||||
|
||||
|
||||
async def test_preset_uses_current_rules_when_version_unfixed(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
world = await _world(seeded)
|
||||
rule = (
|
||||
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
|
||||
).one()
|
||||
client = await auth_client(KA_RF)
|
||||
url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}"
|
||||
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
|
||||
assert listed.status_code == OK, listed.text
|
||||
assert listed.json()["is_version_fixed"] is False
|
||||
assert listed.json()["version_no"] == rule.version_no
|
||||
assert "as_of" not in listed.json()
|
||||
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
|
||||
response = await client.get(url)
|
||||
assert response.status_code == OK, response.text
|
||||
assert (await client.get(f"{url}/export")).status_code == OK
|
||||
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||
assert audit.after_value["rule_no"] == rule.rule_no
|
||||
assert audit.after_value["rule_version_no"] == rule.version_no
|
||||
assert world.version.elimination_rules_version is None
|
||||
world.version.elimination_rules_version = rule.version_no + 1
|
||||
await seeded.flush()
|
||||
assert (await client.get(url)).status_code == NOT_FOUND
|
||||
assert (await client.get(f"{url}/export")).status_code == NOT_FOUND
|
||||
world.version.elimination_rules_version = rule.version_no
|
||||
await seeded.flush()
|
||||
fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
|
||||
assert fixed.status_code == OK, fixed.text
|
||||
assert fixed.json()["is_version_fixed"] is True
|
||||
assert fixed.json()["version_no"] == rule.version_no
|
||||
missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
|
||||
missing = await client.get(missing_url)
|
||||
assert missing.status_code == NOT_FOUND
|
||||
assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
world = await _world(seeded)
|
||||
old_rule, new_rule = (
|
||||
await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2))
|
||||
).all()
|
||||
assert old_rule.valid_to is None and new_rule.valid_to is None
|
||||
new_rule.rule_no = old_rule.rule_no
|
||||
new_rule.version_no = old_rule.version_no + 1
|
||||
await seeded.flush()
|
||||
|
||||
client = await auth_client(KA_RF)
|
||||
base = f"/consolidations/versions/{world.version.id}"
|
||||
response = await client.get(f"{base}/elimination-rules")
|
||||
assert response.status_code == OK, response.text
|
||||
payload = response.json()
|
||||
assert payload["is_version_fixed"] is False
|
||||
assert payload["version_no"] == new_rule.version_no
|
||||
assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)]
|
||||
assert payload["rules"][0]["rule_no"] == old_rule.rule_no
|
||||
|
||||
old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}")
|
||||
assert old_preset.status_code == NOT_FOUND
|
||||
|
||||
|
||||
async def test_unmapped_account_source_is_visible_in_cell_count(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
|
||||
113
api/tests/test_elimination_rules.py
Normal file
113
api/tests/test_elimination_rules.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Выбор версии правил для шахматки без обращения к БД."""
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.enums import StatementType
|
||||
from src.repositories import elimination_rules as rules_repo
|
||||
from src.services import elimination_rules as service_module
|
||||
|
||||
|
||||
def _rule() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
statement_type=StatementType.balance,
|
||||
rule_no=2,
|
||||
version_no=3,
|
||||
side_1="D",
|
||||
measure="closing",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rule", "version_no", "expected"),
|
||||
[
|
||||
(None, 3, False),
|
||||
(SimpleNamespace(version_no=3, valid_to=None), None, True),
|
||||
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False),
|
||||
(SimpleNamespace(version_no=3), 2, False),
|
||||
(SimpleNamespace(version_no=3), 3, True),
|
||||
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
|
||||
],
|
||||
)
|
||||
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
|
||||
assert rules_repo.is_valid_for_version(rule, version_no) is expected
|
||||
|
||||
|
||||
async def test_rules_filter_by_version_or_open_valid_to() -> None:
|
||||
version_no = 3
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
|
||||
|
||||
await rules_repo.list_rules_with_accounts(session, None)
|
||||
query = str(session.execute.await_args.args[0])
|
||||
assert "valid_to IS NULL" in query
|
||||
assert "max(" in query
|
||||
assert "version_no = (SELECT" in query
|
||||
|
||||
await rules_repo.list_rules_with_accounts(session, version_no)
|
||||
assert "valid_to IS NULL" not in str(session.execute.await_args.args[0])
|
||||
assert "version_no =" in str(session.execute.await_args.args[0])
|
||||
|
||||
|
||||
async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session, scope = object(), object()
|
||||
version_id = uuid.uuid4()
|
||||
rule = _rule()
|
||||
account_id = uuid.uuid4()
|
||||
account = SimpleNamespace(company_no=1, account_id=account_id, is_excluded=False)
|
||||
monkeypatch.setattr(
|
||||
service_module.consolidations_repo,
|
||||
"get_version",
|
||||
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=rule.version_no), None)),
|
||||
)
|
||||
listed = AsyncMock(return_value=[(rule, account)])
|
||||
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
|
||||
|
||||
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
|
||||
|
||||
assert result.is_version_fixed is True
|
||||
assert result.version_no == rule.version_no
|
||||
assert [item.id for item in result.rules] == [rule.id]
|
||||
assert result.rules[0].company_1.accounts == [account_id]
|
||||
listed.assert_awaited_once_with(session, rule.version_no)
|
||||
|
||||
|
||||
async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session, scope = object(), object()
|
||||
version_id = uuid.uuid4()
|
||||
rule = _rule()
|
||||
monkeypatch.setattr(
|
||||
service_module.consolidations_repo,
|
||||
"get_version",
|
||||
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
|
||||
)
|
||||
listed = AsyncMock(return_value=[(rule, None)])
|
||||
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
|
||||
|
||||
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
|
||||
|
||||
assert result.is_version_fixed is False
|
||||
assert result.version_no == rule.version_no
|
||||
assert [item.id for item in result.rules] == [rule.id]
|
||||
listed.assert_awaited_once_with(session, None)
|
||||
|
||||
|
||||
async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session, scope = object(), object()
|
||||
monkeypatch.setattr(
|
||||
service_module.consolidations_repo,
|
||||
"get_version",
|
||||
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
|
||||
)
|
||||
listed = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
|
||||
|
||||
result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4())
|
||||
|
||||
assert result.is_version_fixed is False
|
||||
assert result.version_no is None
|
||||
assert result.rules == []
|
||||
listed.assert_awaited_once_with(session, None)
|
||||
@ -1,13 +1,16 @@
|
||||
"""Выбор счетов и расчёт шахматки без обращения к БД."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status as http_status
|
||||
|
||||
from src.api.endpoints.consolidations.intercompany_matrix import router
|
||||
from src.models.enums import AccountKind
|
||||
from src.models.enums import AccountKind, StatementType
|
||||
from src.schemas.consolidations import (
|
||||
IntercompanyMatrixCellOut,
|
||||
IntercompanyMatrixCompanyOut,
|
||||
@ -45,15 +48,17 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
|
||||
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/manual", ("POST",)) 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 ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths
|
||||
|
||||
debit_parent = _account(AccountKind.A)
|
||||
debit_child = _account(AccountKind.A, debit_parent.id)
|
||||
credit_parent = _account(AccountKind.P)
|
||||
credit_child = _account(AccountKind.P, credit_parent.id)
|
||||
by_id, children = matrix._account_tree([debit_parent, debit_child, credit_parent, credit_child])
|
||||
debit = matrix._selected_accounts([debit_parent.id], [], "D", by_id, children)
|
||||
credit = matrix._selected_accounts([credit_parent.id], [], "K", by_id, children)
|
||||
by_id, children = matrix._make_account_tree([debit_parent, debit_child, credit_parent, credit_child])
|
||||
debit = matrix._select_accounts([debit_parent.id], [], "D", by_id, children)
|
||||
credit = matrix._select_accounts([credit_parent.id], [], "K", by_id, children)
|
||||
assert debit == {debit_parent.id, debit_child.id}
|
||||
assert credit == {credit_parent.id, credit_child.id}
|
||||
|
||||
@ -84,6 +89,118 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None:
|
||||
]
|
||||
|
||||
|
||||
async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
parent = _account(AccountKind.AP)
|
||||
child = _account(AccountKind.AP, parent.id)
|
||||
parent.code = "76"
|
||||
child.code = "76.01"
|
||||
version = SimpleNamespace(elimination_rules_version=1, version_no=2)
|
||||
consolidation = SimpleNamespace(company_group_id=uuid.uuid4(), year=2024, quarter=matrix.PeriodQuarter.Q3)
|
||||
osv_service = SimpleNamespace(
|
||||
build_with_members=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
consolidated=SimpleNamespace(rows=[]), version=version, consolidation=consolidation, members={}
|
||||
)
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(matrix.directory_repo, "list_accounts", AsyncMock(return_value=[parent, child]))
|
||||
rule = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
rule_no=6,
|
||||
statement_type=StatementType.balance,
|
||||
article_1_line="1230",
|
||||
article_2_line="1520",
|
||||
version_no=1,
|
||||
side_1="D",
|
||||
measure="turnover",
|
||||
valid_from=datetime.date(2024, 1, 1),
|
||||
valid_to=None,
|
||||
)
|
||||
rule_accounts = [
|
||||
SimpleNamespace(company_no=1, account_id=parent.id, is_excluded=False),
|
||||
SimpleNamespace(company_no=1, account_id=child.id, is_excluded=True),
|
||||
SimpleNamespace(company_no=2, account_id=parent.id, is_excluded=False),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts))
|
||||
)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(scalar_one=lambda: "ГК")))
|
||||
service = matrix.IntercompanyMatrixService(session, None, osv_service)
|
||||
captured = []
|
||||
monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection))
|
||||
|
||||
await service.build_accounts(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].company_1_account_ids == {parent.id}
|
||||
assert captured[0].company_2_account_ids == {parent.id, child.id}
|
||||
assert captured[0].measure == "turnover"
|
||||
|
||||
version.elimination_rules_version = None
|
||||
monkeypatch.setattr(
|
||||
matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no)
|
||||
)
|
||||
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
|
||||
assert info.mode == "PRESET"
|
||||
assert info.debit_account_codes == ("76",)
|
||||
assert info.credit_account_codes == ("76", "76.01")
|
||||
assert info.measure == "turnover"
|
||||
assert info.rule_no == rule.rule_no
|
||||
assert info.rule_version_no == rule.version_no
|
||||
assert info.article_1_line == rule.article_1_line
|
||||
assert info.article_2_line == rule.article_2_line
|
||||
|
||||
|
||||
async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
credit_parent = _account(AccountKind.P)
|
||||
credit_child = _account(AccountKind.P, credit_parent.id)
|
||||
debit_parent = _account(AccountKind.A)
|
||||
debit_child = _account(AccountKind.A, debit_parent.id)
|
||||
accounts = [credit_parent, credit_child, debit_parent, debit_child]
|
||||
by_id, children = matrix._make_account_tree(accounts)
|
||||
rule = SimpleNamespace(
|
||||
version_no=1,
|
||||
valid_to=None,
|
||||
statement_type=StatementType.balance,
|
||||
side_1="K",
|
||||
measure="turnover",
|
||||
)
|
||||
rule_accounts = [
|
||||
SimpleNamespace(company_no=1, account_id=credit_parent.id, is_excluded=False),
|
||||
SimpleNamespace(company_no=1, account_id=credit_child.id, is_excluded=True),
|
||||
SimpleNamespace(company_no=2, account_id=debit_parent.id, is_excluded=False),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, rule_accounts))
|
||||
)
|
||||
service = matrix.IntercompanyMatrixService(object(), None, object())
|
||||
|
||||
selection, selected_rule = await service._resolve_preset_selection(
|
||||
uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), by_id, children
|
||||
)
|
||||
|
||||
assert selected_rule is rule
|
||||
assert selection.company_1_account_ids == {debit_parent.id, debit_child.id}
|
||||
assert selection.company_2_account_ids == {credit_parent.id}
|
||||
assert selection.measure == "turnover"
|
||||
|
||||
|
||||
async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
rule = SimpleNamespace(
|
||||
statement_type=StatementType.income,
|
||||
version_no=1,
|
||||
valid_to=None,
|
||||
)
|
||||
monkeypatch.setattr(matrix.elimination_rules_repo, "rule_with_account", AsyncMock(return_value=(rule, [])))
|
||||
service = matrix.IntercompanyMatrixService(object(), None, object())
|
||||
|
||||
with pytest.raises(matrix.DomainValidationError) as error:
|
||||
await service._resolve_preset_selection(uuid.uuid4(), SimpleNamespace(elimination_rules_version=1), {}, {})
|
||||
|
||||
assert error.value.status == http_status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
assert error.value.code == "INVALID_RULE"
|
||||
|
||||
|
||||
def test_one_sided_reflects_osv_sources_even_when_amount_is_zero() -> None:
|
||||
company_a, company_b = uuid.uuid4(), uuid.uuid4()
|
||||
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
|
||||
|
||||
@ -12,10 +12,11 @@ from openpyxl import load_workbook
|
||||
from src.api.endpoints.consolidations.intercompany_matrix import (
|
||||
export_intercompany_matrix_all,
|
||||
export_intercompany_matrix_manual,
|
||||
export_intercompany_matrix_preset,
|
||||
export_intercompany_result_table,
|
||||
router,
|
||||
)
|
||||
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
|
||||
from src.models.enums import AuditAction, AuditSection, PeriodQuarter, StatementType
|
||||
from src.schemas.consolidations import (
|
||||
IntercompanyMatrixCellOut,
|
||||
IntercompanyMatrixCompanyOut,
|
||||
@ -35,15 +36,22 @@ from src.services.intercompany_matrix_export import (
|
||||
)
|
||||
|
||||
|
||||
def _info(*, mode: str = "MANUAL") -> MatrixExportInfo:
|
||||
def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportInfo:
|
||||
return MatrixExportInfo(
|
||||
group_name='ГК "Кавычки" / Бисквит',
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
version_no=2,
|
||||
mode=mode,
|
||||
debit_account_codes=("62.01",) if mode == "MANUAL" else (),
|
||||
credit_account_codes=("60.01",) if mode == "MANUAL" else (),
|
||||
debit_account_codes=("62.01",) if mode != "ALL" else (),
|
||||
credit_account_codes=("60.01",) if mode != "ALL" else (),
|
||||
measure="turnover" if mode == "PRESET" else "closing",
|
||||
rule_id=rule_id,
|
||||
rule_no=6 if mode == "PRESET" else None,
|
||||
rule_version_no=3 if mode == "PRESET" else None,
|
||||
rule_statement_type=StatementType.balance if mode == "PRESET" else None,
|
||||
article_1_line="1230" if mode == "PRESET" else None,
|
||||
article_2_line="1520" if mode == "PRESET" else None,
|
||||
)
|
||||
|
||||
|
||||
@ -142,7 +150,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold
|
||||
)
|
||||
|
||||
|
||||
async def test_both_export_routes_use_matrix_calculation() -> None:
|
||||
async def test_export_routes_use_matrix_calculation() -> None:
|
||||
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||
account_id = UUID("22222222-2222-2222-2222-222222222222")
|
||||
company_a = IntercompanyMatrixCompanyOut(id=version_id, name="Компания А")
|
||||
@ -169,7 +177,7 @@ async def test_both_export_routes_use_matrix_calculation() -> None:
|
||||
|
||||
async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]:
|
||||
calls.append((args, kwargs))
|
||||
return matrix, _info(mode=kwargs["mode"])
|
||||
return matrix, _info(mode=kwargs["mode"], rule_id=kwargs.get("rule_id"))
|
||||
|
||||
service = SimpleNamespace(build_export=build_export)
|
||||
audit = SimpleNamespace(record=AsyncMock())
|
||||
@ -177,18 +185,37 @@ async def test_both_export_routes_use_matrix_calculation() -> None:
|
||||
|
||||
all_response = await export_intercompany_matrix_all(version_id, service, audit)
|
||||
manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit)
|
||||
preset_response = await export_intercompany_matrix_preset(version_id, account_id, service, audit)
|
||||
|
||||
assert calls == [
|
||||
((version_id,), {"mode": "ALL"}),
|
||||
((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}),
|
||||
((version_id,), {"mode": "PRESET", "rule_id": account_id}),
|
||||
]
|
||||
for response in (all_response, manual_response):
|
||||
for response, expected_info in (
|
||||
(all_response, _info(mode="ALL")),
|
||||
(manual_response, _info(mode="MANUAL")),
|
||||
(preset_response, _info(mode="PRESET", rule_id=account_id)),
|
||||
):
|
||||
assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
disposition = response.headers["content-disposition"]
|
||||
assert disposition.startswith("attachment; filename=\"intercompany-matrix.xlsx\"; filename*=UTF-8''")
|
||||
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == _info().filename
|
||||
assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == expected_info.filename
|
||||
book = load_workbook(BytesIO(response.body))
|
||||
assert book.active["B6"].value in {"Все счета", "Выбранные счета"}
|
||||
assert book.active["B6"].value in {"Все счета", "Выбранные счета", "Пресет"}
|
||||
preset_sheet = load_workbook(BytesIO(preset_response.body)).active
|
||||
assert preset_sheet["B7"].value == "62.01"
|
||||
assert preset_sheet["B8"].value == "60.01"
|
||||
assert preset_sheet["B9"].value == "Оборот"
|
||||
assert [(preset_sheet.cell(row, 1).value, preset_sheet.cell(row, 2).value) for row in range(10, 14)] == [
|
||||
("Правило", "ББ № 6"),
|
||||
("Статья А", "1230"),
|
||||
("Статья Б", "1520"),
|
||||
("Версия правил", "v3"),
|
||||
]
|
||||
assert preset_sheet["A14"].value == "Дебет ↓ / Кредит →"
|
||||
assert preset_sheet.freeze_panes == "C15"
|
||||
assert _info(mode="PRESET", rule_id=account_id).filename.endswith("_rule-balance-6.xlsx")
|
||||
assert [call.kwargs for call in audit.record.await_args_list] == [
|
||||
{
|
||||
"action": AuditAction.EXPORT,
|
||||
@ -218,11 +245,29 @@ async def test_both_export_routes_use_matrix_calculation() -> None:
|
||||
"cells": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"action": AuditAction.EXPORT,
|
||||
"section": AuditSection.EXPORT,
|
||||
"object_type": "consolidation_version",
|
||||
"object_id": version_id,
|
||||
"consolidation_version_id": version_id,
|
||||
"after_value": {
|
||||
"report": "intercompany_matrix",
|
||||
"mode": "PRESET",
|
||||
"debit_account_ids": [],
|
||||
"credit_account_ids": [],
|
||||
"rule_id": str(account_id),
|
||||
"rule_no": 6,
|
||||
"rule_version_no": 3,
|
||||
"cells": 1,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes}
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/all/export", ("GET",)) in paths
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual/export", ("POST",)) in paths
|
||||
assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export", ("GET",)) in paths
|
||||
|
||||
|
||||
async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
Решение владельца 08.10.2026: общий итог элиминации с книгой не сравниваем —
|
||||
аналитик снимает полную сумму каждой стороны, система использует минимум пары.
|
||||
Здесь явно выбраны только точные встречные строки. Для 62.01 это производство
|
||||
молочной булочки (строка 21); аренда (строка 17) в эту пару не входит.
|
||||
Тест проверяет расчёт выбранных пар, а не автоматический подбор строк из всей ОСВ.
|
||||
С листом «01.10.2024» сверяются только точные встречные строки; расхождения по
|
||||
остальным строкам ожидаемы. Для правила № 6: 60.01 АО РБ ↔ 62.01
|
||||
Счастливочной — производство молочной булочки (строка 21), не аренда (строка 17).
|
||||
По займу 42 млн есть 66.03 АО РБ, но в фикстурах нет встречной ОСВ 58.03
|
||||
Счастливочной; её присутствие проверяется отдельно на модельной строке.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -58,6 +60,8 @@ def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - опис
|
||||
) -> None:
|
||||
debit = _source(fixtures_dir, "schastl", debit_root, debit_row)
|
||||
credit = _source(fixtures_dir, "rb", credit_root, credit_row)
|
||||
if amount == "106311116.46":
|
||||
assert (debit["account_code_raw"], credit["account_code_raw"]) == ("62.01", "60.01") # правило № 6
|
||||
assert debit["counterparty_inn"] == "3528052718"
|
||||
assert "СЧАСТЛИВОЧНАЯ" in credit["counterparty_name"]
|
||||
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
|
||||
@ -98,3 +102,44 @@ def test_exact_pairs_against_russian_biscuit_golden( # noqa: PLR0913 - опис
|
||||
workbook.close()
|
||||
# В книге тысячи рублей с округлением до целого; входные ОСВ содержат рубли и копейки.
|
||||
assert (cell.amount_a / 1000).quantize(Decimal("1"), rounding=ROUND_HALF_UP) == expected
|
||||
|
||||
|
||||
def test_loan_66_03_against_58_03_when_counterpart_exists(fixtures_dir: Path) -> None:
|
||||
"""42 млн на 66.03 РБ: без ОСВ 58.03 пара односторонняя, со встречной строкой — точная."""
|
||||
credit = _source(fixtures_dir, "rb", "66", 25)
|
||||
assert credit["account_code_raw"] == "66.03"
|
||||
amount = Decimal("42000000.00")
|
||||
assert Decimal(credit["closing_credit"]) == amount
|
||||
|
||||
debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
|
||||
credit_row = SimpleNamespace(
|
||||
legal_entity_id=RB,
|
||||
counterparty_id=BAKERY,
|
||||
account_id=credit_account,
|
||||
closing_debit=Decimal(0),
|
||||
closing_credit=amount,
|
||||
entry_ids=(uuid.uuid4(),),
|
||||
)
|
||||
companies = [
|
||||
IntercompanyMatrixCompanyOut(id=BAKERY, name="Пекарня Счастливочная"),
|
||||
IntercompanyMatrixCompanyOut(id=RB, name="Русский бисквит"),
|
||||
]
|
||||
selection = MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing") # правило № 4
|
||||
one_sided = calculate_matrix([credit_row], companies, selection).cells[0]
|
||||
assert one_sided.amount_a == 0
|
||||
assert one_sided.amount_b == amount
|
||||
assert one_sided.one_sided is True
|
||||
|
||||
# В наборе нет ОСВ 58.03 Счастливочной; модельная строка проверяет встречную сторону правила № 4.
|
||||
debit_row = SimpleNamespace(
|
||||
legal_entity_id=BAKERY,
|
||||
counterparty_id=RB,
|
||||
account_id=debit_account,
|
||||
closing_debit=amount,
|
||||
closing_credit=Decimal(0),
|
||||
entry_ids=(uuid.uuid4(),),
|
||||
)
|
||||
paired = calculate_matrix([debit_row, credit_row], companies, selection).cells[0]
|
||||
assert paired.amount_a == paired.amount_b == amount
|
||||
assert paired.variance_amount == 0
|
||||
assert paired.one_sided is False
|
||||
|
||||
@ -79,6 +79,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
|
||||
"/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}/export": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"get"},
|
||||
}
|
||||
|
||||
74
openspec/changes/intercompany-matrix-presets/design.md
Normal file
74
openspec/changes/intercompany-matrix-presets/design.md
Normal file
@ -0,0 +1,74 @@
|
||||
## Context
|
||||
|
||||
Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`), задача AURORA-1747 (выделена из AURORA-1536
|
||||
решением владельца 21.10.2026). Расчёт элиминации §5 — сумма по тройке с `elimination_mode` каскадом по `rule_no`,
|
||||
упаковка строк и «без пары», API списка пар, фиксация `elimination_rules_version` — остаётся в AURORA-1536 и в этот
|
||||
change не входит. Шахматка — чистая функция от сводной ОСВ версии,
|
||||
периметра и выбранных счетов; в БД не хранится (DECISION-5). Корректировки (`correction`) — хранимые ручные решения,
|
||||
читаются, но в ядре пока никем не создаются.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Режим PRESET берёт правило по `rule_id`. Правило должно быть действительно для версии: при зафиксированном
|
||||
`elimination_rules_version` — совпадать по `version_no`, иначе — иметь `valid_to IS NULL` и максимальный открытый
|
||||
`version_no`; иначе 404.
|
||||
2. Сторона дебета — `company_1`, если `side_1 = D`, иначе `company_2`; кредит — вторая сторона. Счета и исключения
|
||||
раскрываются по иерархии (`_select_accounts`), как в MANUAL. Нет счетов хотя бы одной стороны — 422
|
||||
`ACCOUNTS_REQUIRED`; неверные `side_1`/`measure`/`company_no` — 422 `INVALID_RULE`; не передан `rule_id` — 422
|
||||
`RULE_REQUIRED`.
|
||||
3. Показатель (`closing`/`turnover`) берётся из правила; ALL и MANUAL остаются на `closing`.
|
||||
4. *Отменено решением владельца 21.10.2026 (п. 3 ниже), в реализацию не входит.* Применённые корректировки правила (`status = APPLIED`, тот же `rule_id` и версия консолидации) уменьшают
|
||||
`amount_a` ячейки `(A, B)` на `debit_adjustment` корректировки (`legal_entity_id = A`, `counterparty_id = B`) и
|
||||
`amount_b` на `credit_adjustment` корректировки `(B, A)`; расхождение, процент и порог пересчитываются, ID
|
||||
корректировок попадают в `correction_ids_debit/credit`.
|
||||
5. Первоначальная ручка `unpaired-osv` отменена решением владельца ниже и в текущую реализацию не входит.
|
||||
6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится.
|
||||
7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`, в шапке «Режим = Пресет»,
|
||||
номер правила, статьи сторон, фактическая версия правил, коды счетов сторон (отсортированы) и строка «Показатель»
|
||||
(Сальдо/Оборот) для всех режимов.
|
||||
|
||||
## Решения владельца (2026-10-21, ревью PR #141)
|
||||
|
||||
1. **Фиксация версии правил — в AURORA-1536 (следующий шаг, не этот change).** Пока `consolidation_version.elimination_rules_version` пуст, шахматка
|
||||
берёт открытые правила (`valid_to IS NULL`); сейчас версия правил одна (`seed/core/elimination_rules_v1.yaml`,
|
||||
`valid_to` не задаётся, API управления правилами нет), поэтому результат не плавает. Фиксировать версию — при
|
||||
создании версии консолидации или при первом расчёте, но не в GET-запросе.
|
||||
2. **Какая версия правил берётся — действующая на дату составления консолидации**, а не на отчётный период версии.
|
||||
Реализация — вместе с п. 1 в AURORA-1536.
|
||||
3. **Корректировки из шахматки убираются** (решение 4 выше и `correction_ids_*` в ячейке). Причины: корректировки в
|
||||
ядре никто не создаёт (спринт корректировок), семантика ключа `(rule, A, B)` относительно ячейки (A, B) / (B, A) не
|
||||
утверждена, счета корректировки и `parent_correction_id` не учитывались; §6 описывает шахматку как суммы первички.
|
||||
Код корректировок сохранён в ветке `wip/shahmatka-corrections-unpaired` (PR #164 не сливается) и возвращается после решения по аналитике
|
||||
AURORA-1643 «Учитывание корректировок в шахматке».
|
||||
4. **Ручка `…/preset/{rule_id}/unpaired-osv` убирается** (решение 5 выше). Причины: «без пары» по наличию строк
|
||||
(дефект AURORA-1651), ложные срабатывания при пересечении счетов сторон (76 в правиле № 6), нет ячейки и стороны
|
||||
строки, расхождение с «без пары» §5. Сценарий закрывают AURORA-1653 (строки ОСВ ячейки) и AURORA-1536 (строки без
|
||||
пары по упаковке §5). Код — в той же ветке `wip/shahmatka-corrections-unpaired`.
|
||||
5. **Пресет = правило Прил. 3 — дополнение к пресетам §6** (наборы счетов), не замена. PRESET ограничивается
|
||||
правилами ББ (`statement_type = balance`): правил ОФР в seed нет, ОФР в §4 открыт. Правка §6 — в `../docs`.
|
||||
|
||||
После выноса п. 3–4 автором соответствующие требования дельты `consolidated-osv` удаляются из change.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена
|
||||
ошибки: автор переписывает change.
|
||||
- 2026-10-21. Спека описывает фактическое поведение PR; спорное вынесено в Known issues (раздел снят в раунде 2 —
|
||||
все пункты закрыты автором). Обоснование: домен решает
|
||||
автор и владелец, не ревьюер. Цена ошибки: после решения владельца спека меняется дельтой.
|
||||
- 2026-10-21. Поле `as_of` удалено из `EliminationRulesOut` как мёртвое: после правок по ревью всегда `None`, в main
|
||||
его не было, потребителей нет. Цена ошибки: если фронт ждал `as_of`, поле нужно вернуть.
|
||||
- 2026-10-21. При незаданной версии правил выбирается максимальный открытый `version_no`; он возвращается в ответе
|
||||
`elimination-rules`, чтобы фронт видел фактически применённую версию.
|
||||
- 2026-10-21. mypy: `measure` аннотирован `Measure` без `cast`; слишком длинная f-строка имени файла свода ОСВ
|
||||
(`consolidated_osv.py`) разбита на две без изменения результата (ruff E501). Цена ошибки: нулевая.
|
||||
- 2026-10-21. В MODIFIED-блоке требования про правила элиминации и аудит экспорта приведён полный текст; требование
|
||||
«XLSX шахматки» (ALL/MANUAL) не менялось, поведение PRESET описано отдельным ADDED-требованием.
|
||||
- 2026-10-22. Раунд 2 ревью: в ветку влит `main` (#163), в README возвращена фраза про XLSX результирующей таблицы,
|
||||
потерянная при merge автора (b7d9794). Показатель пресета в docs (#146, §6) приведён к change: `measure` правила,
|
||||
у правил ББ — `closing`. Цена ошибки: нулевая.
|
||||
- 2026-10-22. Спека шапки xlsx пресета оставлена по фактическому поведению (коды счетов раскрыты по иерархии, статьи —
|
||||
`article_1_line`/`article_2_line`): PR сливается с мелкими замечаниями раунда 2, вынесенными в технический долг
|
||||
AURORA-1761 по решению владельца; целевая шапка (счета правила и «кроме», статьи по сторонам при `side_1 = K`) — там же,
|
||||
с MODIFIED-дельтой спеки. Цена ошибки: до AURORA-1761 шапка пресета неудобна для сверки с Прил. 3, расчёт не затронут.
|
||||
|
||||
33
openspec/changes/intercompany-matrix-presets/proposal.md
Normal file
33
openspec/changes/intercompany-matrix-presets/proposal.md
Normal file
@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
Шахматка ВГО (ТЗ §6) считалась только по всем счетам (ALL) или по счетам, выбранным вручную (MANUAL). Аналитику нужен
|
||||
режим «пресет»: счета и показатель берутся из правила элиминации (Прил. 3).
|
||||
Реализация — PR RSHB/arm-core#141 (ветка `add-preset-shahmatka`, автор — Потапова).
|
||||
Требования оформлены как дельты этого change'а при ревью (решение владельца 21.10.2026); решения владельца и ревью —
|
||||
`design.md`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Режим PRESET шахматки: `GET …/intercompany-matrix/preset/{rule_id}`, выгрузка `…/preset/{rule_id}/export`.
|
||||
- Показатель пресета (`closing` / `turnover`) берётся из правила.
|
||||
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть
|
||||
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`)
|
||||
с максимальным `version_no`; ответ содержит этот номер.
|
||||
- xlsx: для PRESET имя файла с `_rule-balance-<rule_no>`, в шапке номер, статьи, версия правила и «Показатель»; audit `EXPORT` пишет `mode`
|
||||
`ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
- `consolidations-api`: ответ правил элиминации версии.
|
||||
- `consolidated-osv`: режим PRESET шахматки и xlsx пресета.
|
||||
- `audit-trail`: аудит экспорта шахматки (режим PRESET).
|
||||
|
||||
## Impact
|
||||
|
||||
Только `arm-core` (API, сервисы, схемы), таблицы и DBML не меняются. Ломающее изменение контракта
|
||||
`GET …/elimination-rules` (массив → объект); поле `as_of` в ответ не входит. Потребителей старого формата в этом репозитории не проверялось — проверить при слиянии. Non-goals: фиксация версии правил, расчёт «актуальных» правил на отчётную дату, создание и применение корректировок,
|
||||
исправление `one_sided` (AURORA-1651).
|
||||
@ -0,0 +1,20 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Аудит экспорта шахматки
|
||||
Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/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, 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,48 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Шахматка по правилу-пресету
|
||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}`
|
||||
(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать шахматку в режиме PRESET с тем же ответом, что ALL и MANUAL.
|
||||
Счета дебета и кредита SHALL браться из правила элиминации `rule_id`: дебет — сторона `company_1`, если
|
||||
`side_1 = D`, иначе `company_2`; кредит — вторая сторона; исключения (`excluded`) вычитаются, выбранные счета
|
||||
раскрываются по иерархии и фильтруются по виду, как в MANUAL. Показатель SHALL браться из `measure` правила:
|
||||
`closing` — конечное сальдо (`closing_debit`/`closing_credit`), `turnover` — обороты (`turnover_debit`/`turnover_credit`).
|
||||
Правило SHALL быть действительным для версии консолидации: при заданном `elimination_rules_version` — с тем же
|
||||
`version_no`, иначе — без `valid_to` и с максимальным открытым `version_no`; несуществующее или недействительное правило SHALL давать 404. Ошибки 422:
|
||||
`RULE_REQUIRED` — правило не выбрано, `INVALID_RULE` — правило ОФР либо неверные `side_1`, `measure` или сторона счёта правила,
|
||||
`ACCOUNTS_REQUIRED` — у одной из сторон нет счетов. Невидимая и несуществующая версия SHALL отвечать 404 `NOT_FOUND`.
|
||||
|
||||
#### Scenario: Пресет по сальдо
|
||||
- **WHEN** правило с `side_1 = D`, `measure = closing` содержит дебетовые счета на стороне 1 и кредитовые на стороне 2
|
||||
- **THEN** `amount_a` пары — сумма `closing_debit` по счетам стороны 1, `amount_b` — сумма `closing_credit` по счетам стороны 2
|
||||
|
||||
#### Scenario: Пресет по оборотам
|
||||
- **WHEN** у правила `measure = turnover`
|
||||
- **THEN** суммы считаются по `turnover_debit`/`turnover_credit`
|
||||
|
||||
#### Scenario: Правило другой версии
|
||||
- **WHEN** у версии `elimination_rules_version = 2`, а `rule_id` принадлежит правилу версии 1
|
||||
- **THEN** 404 для расчёта и выгрузки
|
||||
|
||||
#### Scenario: Неизвестное правило
|
||||
- **WHEN** `rule_id` не существует
|
||||
- **THEN** 404
|
||||
|
||||
#### Scenario: Правило ОФР
|
||||
- **WHEN** у правила `statement_type = income`
|
||||
- **THEN** расчёт и выгрузка возвращают 422 `INVALID_RULE`
|
||||
|
||||
### Requirement: XLSX шахматки пресета
|
||||
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
|
||||
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
|
||||
содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер
|
||||
правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
|
||||
«Сальдо»).
|
||||
|
||||
#### Scenario: Выгрузка по оборотам
|
||||
- **WHEN** пользователь выгружает пресет с `measure = turnover`
|
||||
- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-<rule_no>.xlsx`
|
||||
|
||||
#### Scenario: Правило не найдено
|
||||
- **WHEN** `rule_id` не существует
|
||||
- **THEN** 404, файл не отдаётся, событие аудита не пишется
|
||||
@ -0,0 +1,28 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Правила элиминации версии
|
||||
`GET /api/v1/consolidations/versions/{version_id}/elimination-rules` (`CONSOLIDATION_VIEW`, policy по ГК)
|
||||
SHALL возвращать объект `{version_no, is_version_fixed, rules}`. Если у версии задан
|
||||
`consolidation_version.elimination_rules_version`, `version_no` SHALL равняться ему, `is_version_fixed = true`, а
|
||||
`rules` — правилами этой версии. Если он не задан, `is_version_fixed = false`, а `rules` —
|
||||
открытые правила (`valid_to IS NULL`) только с максимальным `version_no`; ответ SHALL содержать этот фактический
|
||||
`version_no`. При отсутствии открытых правил `version_no = null`, `rules = []`. Каждое правило SHALL содержать `id`, `rule_no`,
|
||||
`version_no`, `statement_type`, `side_1` (`D`/`K`), `measure` (`closing`/`turnover`), `company_1`, `company_2`;
|
||||
каждая сторона SHALL содержать `accounts: UUID[]` и `excluded: UUID[]` до раскрытия иерархии счетов.
|
||||
Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`.
|
||||
|
||||
#### Scenario: Правило с исключением
|
||||
- **WHEN** у правила первая сторона включает счёт 76 и исключает 76.07.5
|
||||
- **THEN** ID 76 находится в `company_1.accounts`, ID 76.07.5 — в `company_1.excluded`
|
||||
|
||||
#### Scenario: Версия правил зафиксирована
|
||||
- **WHEN** у версии консолидации `elimination_rules_version = 1`
|
||||
- **THEN** ответ содержит `is_version_fixed = true`, `version_no = 1` и только правила с `version_no = 1`
|
||||
|
||||
#### Scenario: Версия правил не зафиксирована
|
||||
- **WHEN** у версии консолидации `elimination_rules_version` пуст
|
||||
- **THEN** ответ содержит `is_version_fixed = false`, фактический `version_no` и открытые правила только этой версии
|
||||
|
||||
#### Scenario: Открыты две версии правил
|
||||
- **WHEN** открыты правила с `version_no = 1` и `version_no = 2`, в том числе с одинаковым `rule_no`
|
||||
- **THEN** ответ содержит только правила версии 2 и `version_no = 2`
|
||||
23
openspec/changes/intercompany-matrix-presets/tasks.md
Normal file
23
openspec/changes/intercompany-matrix-presets/tasks.md
Normal file
@ -0,0 +1,23 @@
|
||||
## 1. Реализация
|
||||
|
||||
Реализация выполнена автором в PR RSHB/arm-core#141; задач плана нет. Доработки по замечаниям ревью — комментарии PR
|
||||
(раунды 1–2) и разделы решений в `design.md`.
|
||||
|
||||
## 2. Доработки по решениям владельца 2026-10-21 (автор)
|
||||
|
||||
- [x] 2.1 Отдельным коммитом убрать учёт корректировок (`_apply_corrections`, `correction_for_version_consolidation`,
|
||||
`correction_ids_*`) вместе с тестами; код сохранён в `wip/shahmatka-corrections-unpaired`.
|
||||
- [x] 2.1a Убрать ручку `unpaired-osv` (`get_unpaired_rows`, `UnpairedOsvOut`, маршрут) вместе с тестами;
|
||||
код сохранён в `wip/shahmatka-corrections-unpaired`.
|
||||
- [x] 2.2 PRESET — только правила `statement_type = balance`; правило ОФР — 422 `INVALID_RULE`, тест.
|
||||
- [x] 2.3 Несколько открытых `version_no` — только максимальная версия; вернуть её номер, тест.
|
||||
- [x] 2.4 Ревьюер: убрать из дельты `consolidated-osv` требования про корректировки и `unpaired-osv`, обновить README.
|
||||
|
||||
## 3. Ревью, раунд 2 (2026-10-22)
|
||||
|
||||
- [x] 3.1 Ревьюер: влить `main`, вернуть фразу README про XLSX результирующей таблицы, поправить design/proposal/tasks.
|
||||
- [x] 3.2 Ревьюер: показатель пресета в docs #146 §6 — `measure` правила.
|
||||
- [x] 3.3 Мелкие замечания раунда 2 (шапка xlsx пресета — счета правила и «кроме», подписи статей при `side_1 = K`,
|
||||
лишняя проверка в `_export_response`, реквизиты правила в `MatrixExportInfo` и `_filename`, описания ошибок preset в
|
||||
OpenAPI, проверка правила до расчёта свода) вынесены в технический долг AURORA-1761 (решение владельца 22.10.2026) — см. design.md.
|
||||
- [ ] 3.4 После слияния: архив change.
|
||||
Loading…
x
Reference in New Issue
Block a user