Merge pull request 'aurora-1761' (#223) from aurora-1761 into main

AURORA-1761: шапка XLSX пресета шахматки — коды правила, строки «кроме», статьи по сторонам
This commit is contained in:
aep 2026-11-12 09:21:01 +03:00
commit 8a82ec2aff
10 changed files with 273 additions and 78 deletions

View File

@ -34,11 +34,20 @@ RESULT_ERRORS: dict[int | str, dict[str, Any]] = {
"description": "В плане счетов нет счетов статей результирующей таблицы", "description": "В плане счетов нет счетов статей результирующей таблицы",
}, },
} }
PRESET_ERRORS: dict[int | str, dict[str, Any]] = {
**ERRORS,
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия или правило не найдены либо недоступны"},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"model": ErrorDetail,
"description": "INVALID_RULE, RULE_REQUIRED или ACCOUNTS_REQUIRED",
},
}
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
XLSX_CONTENT: dict[int | str, dict[str, Any]] = { XLSX_CONTENT: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}} status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}
} }
EXPORT_RESPONSES = {**ERRORS, **XLSX_CONTENT} EXPORT_RESPONSES = {**ERRORS, **XLSX_CONTENT}
PRESET_EXPORT_RESPONSES = {**PRESET_ERRORS, **XLSX_CONTENT}
RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT} RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT}
@ -59,8 +68,6 @@ async def _export_response(
selection: IntercompanyMatrixSelectionIn | None = None, selection: IntercompanyMatrixSelectionIn | None = None,
) -> Response: ) -> Response:
content = await run_in_threadpool(build_matrix_workbook, matrix, info) content = await run_in_threadpool(build_matrix_workbook, matrix, info)
if info.mode == "PRESET" and (info.rule_id is None or info.rule_no is None or info.rule_version_no is None):
raise ValueError("Для аудита PRESET нужны ID, номер и версия правила")
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx") response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx")
await audit.record( await audit.record(
action=AuditAction.EXPORT, action=AuditAction.EXPORT,
@ -76,11 +83,11 @@ async def _export_response(
"legal_entity_ids": [str(entity_id) for entity_id in selection.legal_entity_ids] if selection else [], "legal_entity_ids": [str(entity_id) for entity_id in selection.legal_entity_ids] if selection else [],
**( **(
{ {
"rule_id": str(info.rule_id), "rule_id": str(info.preset.id),
"rule_no": info.rule_no, "rule_no": info.preset.rule_no,
"rule_version_no": info.rule_version_no, "rule_version_no": info.preset.version_no,
} }
if info.mode == "PRESET" if info.preset is not None
else {} else {}
), ),
"cells": len(matrix.cells), "cells": len(matrix.cells),
@ -137,7 +144,7 @@ async def export_intercompany_matrix(
"/{version_id}/intercompany-matrix/preset/{rule_id}", "/{version_id}/intercompany-matrix/preset/{rule_id}",
response_model=IntercompanyMatrixOut, response_model=IntercompanyMatrixOut,
dependencies=VIEW, dependencies=VIEW,
responses=ERRORS, responses=PRESET_ERRORS,
summary="Рассчитать шахматку по пресету", summary="Рассчитать шахматку по пресету",
) )
async def build_intercompany_matrix_preset( async def build_intercompany_matrix_preset(
@ -153,7 +160,7 @@ async def build_intercompany_matrix_preset(
"/{version_id}/intercompany-matrix/preset/{rule_id}/export", "/{version_id}/intercompany-matrix/preset/{rule_id}/export",
response_class=Response, response_class=Response,
dependencies=VIEW, dependencies=VIEW,
responses=EXPORT_RESPONSES, responses=PRESET_EXPORT_RESPONSES,
summary="Скачать шахматку по пресету в XLSX", summary="Скачать шахматку по пресету в XLSX",
) )
async def export_intercompany_matrix_preset( async def export_intercompany_matrix_preset(

View File

@ -5,7 +5,7 @@ from collections import defaultdict
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal, cast
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -110,6 +110,10 @@ class MatrixSelection:
company_1_account_ids: frozenset[uuid.UUID] company_1_account_ids: frozenset[uuid.UUID]
company_2_account_ids: frozenset[uuid.UUID] company_2_account_ids: frozenset[uuid.UUID]
measure: Measure measure: Measure
debit_rule_codes: tuple[str, ...] = ()
credit_rule_codes: tuple[str, ...] = ()
debit_excluded_codes: tuple[str, ...] = ()
credit_excluded_codes: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@ -133,8 +137,21 @@ class VersionExportInfo:
quarter: PeriodQuarter quarter: PeriodQuarter
version_no: int version_no: int
def _filename(self, prefix: str) -> str: def _filename(self, prefix: str, suffix: str = "") -> str:
return f"{prefix}_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" return f"{prefix}_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{suffix}.xlsx"
@dataclass(frozen=True)
class PresetRuleDetails:
id: uuid.UUID
rule_no: int
version_no: int
statement_type: StatementType
article_1_line: str
article_2_line: str
side_1: Side
debit_excluded_codes: tuple[str, ...] = ()
credit_excluded_codes: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@ -143,37 +160,12 @@ class MatrixExportInfo(VersionExportInfo):
debit_account_codes: tuple[str, ...] debit_account_codes: tuple[str, ...]
credit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...]
measure: Measure = "closing" measure: Measure = "closing"
rule_id: uuid.UUID | None = None preset: PresetRuleDetails | 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 @property
def filename(self) -> str: def filename(self) -> str:
preset = "" suffix = f"_rule-{self.preset.statement_type.value}-{self.preset.rule_no}" if self.preset else ""
if self.mode == "PRESET": return self._filename("intercompany-matrix", suffix)
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) @dataclass(frozen=True)
@ -730,8 +722,8 @@ class IntercompanyMatrixService:
"PRESET" if rule_id is not None else "MANUAL" if debit_account_ids or credit_account_ids else "ALL" "PRESET" if rule_id is not None else "MANUAL" if debit_account_ids or credit_account_ids else "ALL"
) )
if mode == "PRESET": if mode == "PRESET":
debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids)) debit_codes = result.selection.debit_rule_codes
credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids)) credit_codes = result.selection.credit_rule_codes
else: else:
debit_codes = tuple(by_id[account_id] for account_id in debit_account_ids) 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) credit_codes = tuple(by_id[account_id] for account_id in credit_account_ids)
@ -744,12 +736,19 @@ class IntercompanyMatrixService:
debit_account_codes=debit_codes, debit_account_codes=debit_codes,
credit_account_codes=credit_codes, credit_account_codes=credit_codes,
measure=result.selection.measure, measure=result.selection.measure,
rule_id=result.rule.id if result.rule is not None else None, preset=PresetRuleDetails(
rule_no=result.rule.rule_no if result.rule is not None else None, id=result.rule.id,
rule_version_no=result.rule.version_no if result.rule is not None else None, rule_no=result.rule.rule_no,
rule_statement_type=result.rule.statement_type if result.rule is not None else None, version_no=result.rule.version_no,
article_1_line=result.rule.article_1_line if result.rule is not None else None, statement_type=result.rule.statement_type,
article_2_line=result.rule.article_2_line if result.rule is not None else None, article_1_line=result.rule.article_1_line,
article_2_line=result.rule.article_2_line,
side_1=cast(Side, result.rule.side_1),
debit_excluded_codes=result.selection.debit_excluded_codes,
credit_excluded_codes=result.selection.credit_excluded_codes,
)
if result.rule is not None
else None,
) )
return result.matrix, info return result.matrix, info
@ -790,7 +789,18 @@ class IntercompanyMatrixService:
credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children) credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children)
measure: Measure = "closing" if rule.measure == "closing" else "turnover" measure: Measure = "closing" if rule.measure == "closing" else "turnover"
return MatrixSelection(debit, credit, measure), rule def codes(ids: Sequence[uuid.UUID]) -> tuple[str, ...]:
return tuple(sorted(by_id[account_id].code for account_id in ids))
return MatrixSelection(
debit,
credit,
measure,
debit_rule_codes=codes(debit_ids),
credit_rule_codes=codes(credit_ids),
debit_excluded_codes=codes(debit_excluded),
credit_excluded_codes=codes(credit_excluded),
), rule
async def _build_accounts( async def _build_accounts(
self, self,

View File

@ -57,18 +57,22 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int:
extra = [ extra = [
("Режим", mode_name), ("Режим", mode_name),
("Счета дебета", ", ".join(info.debit_account_codes) or all_accounts), ("Счета дебета", ", ".join(info.debit_account_codes) or all_accounts),
("Счета кредита", ", ".join(info.credit_account_codes) or all_accounts),
("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"),
] ]
if info.mode == "PRESET": if info.preset is not None and info.preset.debit_excluded_codes:
statement_type, rule_no, rule_version_no, article_1_line, article_2_line = info.preset_details extra.append(("кроме", ", ".join(info.preset.debit_excluded_codes)))
rule_type = "ББ" if statement_type.value == "balance" else "ОФР" extra.append(("Счета кредита", ", ".join(info.credit_account_codes) or all_accounts))
if info.preset is not None and info.preset.credit_excluded_codes:
extra.append(("кроме", ", ".join(info.preset.credit_excluded_codes)))
extra.append(("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"))
if info.preset is not None:
preset = info.preset
rule_type = "ББ" if preset.statement_type.value == "balance" else "ОФР"
extra.extend( extra.extend(
( (
("Правило", f"{rule_type} № {rule_no}"), ("Правило", f"{rule_type} № {preset.rule_no}"),
("Статья А", article_1_line), ("Статья А", preset.article_1_line if preset.side_1 != "K" else preset.article_2_line),
("Статья Б", article_2_line), ("Статья Б", preset.article_2_line if preset.side_1 != "K" else preset.article_1_line),
("Версия правил", f"v{rule_version_no}"), ("Версия правил", f"v{preset.version_no}"),
) )
) )
return _write_details( return _write_details(

View File

@ -13,7 +13,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser from src.models.access import AppUser
from src.models.accounting import ChartOfAccount, EliminationRule from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRuleAccount
from src.models.audit_ex import AuditEvent from src.models.audit_ex import AuditEvent
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
@ -67,6 +67,19 @@ async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, sele
return await client.get(f"{base}/{suffix}") return await client.get(f"{base}/{suffix}")
async def _rule_codes(session: AsyncSession, rule: EliminationRule) -> dict[tuple[int, bool], str]:
"""Коды сторон правила как в шапке XLSX пресета: (company_no, is_excluded) → «код, код»."""
rows = await session.execute(
select(EliminationRuleAccount.company_no, EliminationRuleAccount.is_excluded, ChartOfAccount.code)
.join(ChartOfAccount, ChartOfAccount.id == EliminationRuleAccount.account_id)
.where(EliminationRuleAccount.rule_id == rule.id)
)
grouped: dict[tuple[int, bool], list[str]] = {}
for company_no, is_excluded, code in rows:
grouped.setdefault((company_no, is_excluded), []).append(code)
return {key: ", ".join(sorted(values)) for key, values in grouped.items()}
async def _source( async def _source(
session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None
) -> uuid.UUID: ) -> uuid.UUID:
@ -204,16 +217,22 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact
assert exported.status_code == OK, exported.text assert exported.status_code == OK, exported.text
assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"]) assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"])
sheet = load_workbook(BytesIO(exported.content))["Шахматка"] sheet = load_workbook(BytesIO(exported.content))["Шахматка"]
codes = await _rule_codes(seeded, rule)
assert sheet["B6"].value == "Пресет" assert sheet["B6"].value == "Пресет"
assert (sheet["A7"].value, sheet["B7"].value) == ("Счета дебета", codes[(1, False)])
assert (sheet["A8"].value, sheet["B8"].value) == ("кроме", codes[(1, True)])
assert (sheet["A9"].value, sheet["B9"].value) == ("Счета кредита", codes[(2, False)])
assert (sheet["A10"].value, sheet["B10"].value) == ("кроме", codes[(2, True)])
assert "76" in codes[(1, False)].split(", ")
assert "76.07.5" not in sheet["B7"].value assert "76.07.5" not in sheet["B7"].value
assert "76.07.1" not in sheet["B8"].value assert "76.07.1" in sheet["B10"].value
assert sheet["B9"].value == "Сальдо" assert sheet["B11"].value == "Сальдо"
assert sheet["B10"].value == f"ББ № {rule.rule_no}" assert sheet["B12"].value == f"ББ № {rule.rule_no}"
assert sheet["B11"].value == rule.article_1_line assert sheet["B13"].value == rule.article_1_line
assert sheet["B12"].value == rule.article_2_line assert sheet["B14"].value == rule.article_2_line
assert sheet["B13"].value == f"v{rule.version_no}" assert sheet["B15"].value == f"v{rule.version_no}"
assert Decimal(str(sheet["D16"].value)) == Decimal("100") assert Decimal(str(sheet["D18"].value)) == Decimal("100")
assert Decimal(str(sheet["D17"].value)) == Decimal("90") assert Decimal(str(sheet["D19"].value)) == Decimal("90")
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["mode"] == "PRESET" assert audit.after_value["mode"] == "PRESET"
assert audit.after_value["rule_id"] == str(rule.id) assert audit.after_value["rule_id"] == str(rule.id)

View File

@ -93,6 +93,18 @@ def test_routes_and_matrix_expands_manual_accounts() -> None:
] ]
def test_preset_routes_describe_rule_errors() -> None:
preset_routes = [route for route in router.routes if "/intercompany-matrix/preset/{rule_id}" in route.path]
assert {route.path for route in preset_routes} == {
"/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}",
"/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export",
}
for route in preset_routes:
assert "правило" in route.responses[404]["description"]
description = route.responses[422]["description"]
assert all(code in description for code in ("INVALID_RULE", "RULE_REQUIRED", "ACCOUNTS_REQUIRED"))
def test_matrix_totals_accumulate_each_company_and_side() -> None: def test_matrix_totals_accumulate_each_company_and_side() -> None:
company_a, company_b, company_c = (uuid.uuid4() for _ in range(3)) company_a, company_b, company_c = (uuid.uuid4() for _ in range(3))
debit_account, credit_account = uuid.uuid4(), uuid.uuid4() debit_account, credit_account = uuid.uuid4(), uuid.uuid4()
@ -245,12 +257,16 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
_, info = await service.build_export(uuid.uuid4(), rule_id=uuid.uuid4()) _, info = await service.build_export(uuid.uuid4(), rule_id=uuid.uuid4())
assert info.mode == "PRESET" assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",) assert info.debit_account_codes == ("76",)
assert info.credit_account_codes == ("76", "76.01") assert info.credit_account_codes == ("76",)
assert info.preset is not None
assert info.preset.debit_excluded_codes == ("76.01",)
assert info.preset.credit_excluded_codes == ()
assert info.preset.side_1 == "D"
assert info.measure == "turnover" assert info.measure == "turnover"
assert info.rule_no == rule.rule_no assert info.preset.rule_no == rule.rule_no
assert info.rule_version_no == rule.version_no assert info.preset.version_no == rule.version_no
assert info.article_1_line == rule.article_1_line assert info.preset.article_1_line == rule.article_1_line
assert info.article_2_line == rule.article_2_line assert info.preset.article_2_line == rule.article_2_line
async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytest.MonkeyPatch) -> None: async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytest.MonkeyPatch) -> None:
@ -258,6 +274,10 @@ async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytes
credit_child = _account(AccountKind.P, credit_parent.id) credit_child = _account(AccountKind.P, credit_parent.id)
debit_parent = _account(AccountKind.A) debit_parent = _account(AccountKind.A)
debit_child = _account(AccountKind.A, debit_parent.id) debit_child = _account(AccountKind.A, debit_parent.id)
credit_parent.code = "76"
credit_child.code = "76.07.1"
debit_parent.code = "62"
debit_child.code = "62.01"
accounts = [credit_parent, credit_child, debit_parent, debit_child] accounts = [credit_parent, credit_child, debit_parent, debit_child]
by_id, children = matrix._make_account_tree(accounts) by_id, children = matrix._make_account_tree(accounts)
rule = SimpleNamespace( rule = SimpleNamespace(
@ -285,6 +305,9 @@ async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytes
assert selection.company_1_account_ids == {debit_parent.id, debit_child.id} assert selection.company_1_account_ids == {debit_parent.id, debit_child.id}
assert selection.company_2_account_ids == {credit_parent.id} assert selection.company_2_account_ids == {credit_parent.id}
assert selection.measure == "turnover" assert selection.measure == "turnover"
assert selection.debit_rule_codes == ("62",)
assert selection.credit_rule_codes == ("76",)
assert selection.credit_excluded_codes == ("76.07.1",)
async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None: async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -28,7 +28,7 @@ from src.schemas.consolidations import (
IntercompanyResultArticleOut, IntercompanyResultArticleOut,
IntercompanyResultTableOut, IntercompanyResultTableOut,
) )
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo from src.services.intercompany_matrix import MatrixExportInfo, PresetRuleDetails, ResultTableExportInfo
from src.services.intercompany_matrix_export import ( from src.services.intercompany_matrix_export import (
DIAGONAL_FILL, DIAGONAL_FILL,
METRICS, METRICS,
@ -49,13 +49,59 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI
debit_account_codes=("62.01",) if mode != "ALL" else (), debit_account_codes=("62.01",) if mode != "ALL" else (),
credit_account_codes=("60.01",) if mode != "ALL" else (), credit_account_codes=("60.01",) if mode != "ALL" else (),
measure="turnover" if mode == "PRESET" else "closing", measure="turnover" if mode == "PRESET" else "closing",
rule_id=rule_id, preset=PresetRuleDetails(
rule_no=6 if mode == "PRESET" else None, id=rule_id or UUID(int=0),
rule_version_no=3 if mode == "PRESET" else None, rule_no=6,
rule_statement_type=StatementType.balance if mode == "PRESET" else None, version_no=3,
article_1_line="1230" if mode == "PRESET" else None, statement_type=StatementType.balance,
article_2_line="1520" if mode == "PRESET" else None, article_1_line="1230",
article_2_line="1520",
side_1="D",
) )
if mode == "PRESET"
else None,
)
def test_preset_header_shows_rule_6_codes_and_exclusions() -> None:
matrix = IntercompanyMatrixOut(companies=[], cells=[])
info = replace(
_info(mode="PRESET"),
debit_account_codes=("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"),
credit_account_codes=("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"),
preset=replace(
_info(mode="PRESET").preset,
debit_excluded_codes=("76.07.5", "76.07.9"),
credit_excluded_codes=("76.07.1",),
side_1="D",
),
)
sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, info))).active
assert [(sheet.cell(row, 1).value, sheet.cell(row, 2).value) for row in range(7, 16)] == [
("Счета дебета", "60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76"),
("кроме", "76.07.5, 76.07.9"),
("Счета кредита", "60.01, 60.03, 60.21, 60.31, 62.02, 62.22, 62.32, 76"),
("кроме", "76.07.1"),
("Показатель", "Оборот"),
("Правило", "ББ № 6"),
("Статья А", "1230"),
("Статья Б", "1520"),
("Версия правил", "v3"),
]
def test_preset_header_swaps_articles_for_credit_first_rule() -> None:
matrix = IntercompanyMatrixOut(companies=[], cells=[])
info = _info(mode="PRESET")
assert info.preset is not None
info = replace(info, preset=replace(info.preset, side_1="K"))
sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, info))).active
assert [(sheet.cell(row, 1).value, sheet.cell(row, 2).value) for row in (11, 12)] == [
("Статья А", "1520"),
("Статья Б", "1230"),
]
def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold() -> None: def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold() -> None:

View File

@ -0,0 +1,11 @@
# Design
## Decisions made without discussion
1. **2026-11-11. Сценарий правила № 6 без перечня кодов.** После AURORA-2034 (seed правила 6 исключает 15 субсчетов 76
на обеих сторонах) сценарий автора «кроме 76.07.5, 76.07.9» / «кроме 76.07.1» устарел; в спеке сценарий описан без
перечня кодов, точный список проверяет тест seed. Цена ошибки: спека не ловит расхождение шапки с seed — его ловит
`test_seed_rules.py`.
2. **2026-11-11. Влив main: пустая сторона в шапке.** В main ALL/MANUAL объединены, у MANUAL сторона может быть пустой;
при разрешении конфликта шапка пишет «Все балансовые счета» для пустой стороны (`or all_accounts`, как в main), а не
по режиму. Цена ошибки: для PRESET сторона пустой не бывает (`ACCOUNTS_REQUIRED`), на шапку пресета не влияет.

View File

@ -0,0 +1,34 @@
# Proposal
## Why
AURORA-1761 (PR arm-core #223): техдолг ревью PR #141 (пресет шахматки по правилу ББ, AURORA-1747). Шапка XLSX пресета
перечисляла все раскрытые субсчета сторон правила: для правила № 6 — около 50 кодов в одной ячейке, а исключения
субсчетов 76 не видны вовсе, шапку нельзя сверить с Прил. 3. Подписи «Статья А / Статья Б» не учитывали `side_1 = K`.
## What Changes
- Шапка XLSX пресета: «Счета дебета» / «Счета кредита» — коды так, как они заданы в правиле; под каждой стороной строка
«кроме» с исключёнными кодами этой стороны.
- «Статья А» — статья дебетовой стороны, «Статья Б» — кредитовой: при `side_1 = K` статьи меняются местами.
- Реквизиты правила в `MatrixExportInfo` — вложенный `PresetRuleDetails` (None для ALL/MANUAL) вместо шести
необязательных полей; имя файла через общий `VersionExportInfo._filename`.
- Из `_export_response` убрана проверка с `raise ValueError` (давала 500).
- OpenAPI маршрутов preset: 404 — версия или правило, 422 — `INVALID_RULE` / `RULE_REQUIRED` / `ACCOUNTS_REQUIRED`.
## Capabilities
### Modified Capabilities
- `consolidated-osv` — «XLSX шахматки пресета»: коды правила без раскрытия, строки «кроме», статьи по сторонам.
## Impact
arm-core `api/src/services/intercompany_matrix.py`, `api/src/services/intercompany_matrix_export.py`,
`api/src/api/endpoints/consolidations/intercompany_matrix.py`, тесты шахматки. JSON-ответ расчёта, аудит, DBML,
`05-domain-rules.md` не меняются.
## Non-goals
- Проверка правила до расчёта сводной ОСВ (необязательный пункт задачи) — не сделана: на неверный `rule_id` по-прежнему
сначала считается свод.

View File

@ -0,0 +1,29 @@
## MODIFIED Requirements
### Requirement: XLSX шахматки пресета
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
содержать режим «Пресет», коды счетов дебета и кредита так, как они заданы в правиле (без раскрытия по иерархии), под
каждой стороной — строку «кроме» с исключёнными кодами этой стороны, если исключения есть, номер правила, статьи А и Б,
фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`.
Статья А SHALL относиться к дебетовой стороне, статья Б — к кредитовой: `article_1_line` / `article_2_line` при
`side_1 = D`, наоборот при `side_1 = K`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
«Сальдо»).
#### Scenario: Правило с исключениями на обеих сторонах
- **WHEN** пользователь выгружает пресет правила № 6 из seed-набора Прил. 3 (маска «76» на обеих сторонах с
исключениями субсчетов 76)
- **THEN** «Счета дебета» и «Счета кредита» содержат только коды сторон правила, включая «76», без раскрытых субсчетов;
под каждой стороной строка «кроме» перечисляет исключённые коды этой стороны
#### Scenario: Кредитная первая сторона правила
- **WHEN** пользователь выгружает пресет с `side_1 = K`
- **THEN** «Статья А» содержит `article_2_line`, «Статья Б» — `article_1_line`
#### Scenario: Выгрузка по оборотам
- **WHEN** пользователь выгружает пресет с `measure = turnover`
- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-<rule_no>.xlsx`
#### Scenario: Правило не найдено
- **WHEN** `rule_id` не существует
- **THEN** 404, файл не отдаётся, событие аудита не пишется

View File

@ -0,0 +1,12 @@
# Tasks
- [x] 1.1 arm-core `api/src/services/intercompany_matrix.py` — `PresetRuleDetails`, коды сторон правила и исключений в
`MatrixSelection`, `filename` через `_filename` (PR #223, автор)
- [x] 1.2 arm-core `api/src/services/intercompany_matrix_export.py` — строки «кроме», статьи А/Б по `side_1` (PR #223, автор)
- [x] 1.3 arm-core `api/src/api/endpoints/consolidations/intercompany_matrix.py` — `PRESET_ERRORS`, без `raise ValueError`
в `_export_response` (PR #223, автор)
- [x] 1.4 Тесты: шапка правила № 6 с исключениями, `side_1 = K`, описания ошибок маршрутов preset (PR #223, автор)
- [x] 1.5 Ревью: влит main (объединённые ALL/MANUAL, AURORA-2034), пустая сторона MANUAL в шапке — «Все балансовые счета»;
правка спеки перенесена из `openspec/specs` в этот change
- [x] 1.6 Ревью: интеграционный `test_preset_uses_rule_sides_and_exclusions` переведён на новую шапку (строки «кроме»
под сторонами, коды правила сверяются с `elimination_rule_account` из seed, данные матрицы сдвинуты на две строки)