Merge pull request 'add-preset-shahmatka' (#141) from add-preset-shahmatka into main

This commit is contained in:
aep 2026-10-22 17:57:13 +03:00
commit 96f2b9a6d9
21 changed files with 1066 additions and 132 deletions

View File

@ -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`.
Расчёт читает сводную ОСВ версии, результат не хранится.

View File

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

View File

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

View File

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

View File

@ -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):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)

View File

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

View File

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

View File

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

View File

@ -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"},
}

View 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, расчёт не затронут.

View 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).

View File

@ -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`, пустые массивы счетов и число ячеек

View File

@ -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, файл не отдаётся, событие аудита не пишется

View File

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

View 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.