From e5b998df20383c6b8053e747d02e440c2fd98f59 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 5 Nov 2026 17:29:27 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=20=D0=B8=D1=81=D0=BA=D0=BE=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=B2=D1=8B=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D1=83=20(=D1=88=D0=B0=D1=85=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82=D1=8B?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/services/intercompany_matrix.py | 25 ++++++++++++++--- .../services/intercompany_matrix_export.py | 12 ++++++--- api/tests/test_intercompany_matrix.py | 12 ++++++++- api/tests/test_intercompany_matrix_export.py | 27 +++++++++++++++++++ openspec/specs/consolidated-osv/spec.md | 4 +-- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index cd3d7e3..2052745 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -90,6 +90,10 @@ class MatrixSelection: company_1_account_ids: frozenset[uuid.UUID] company_2_account_ids: frozenset[uuid.UUID] 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) @@ -129,6 +133,9 @@ class MatrixExportInfo(VersionExportInfo): rule_statement_type: StatementType | None = None article_1_line: str | None = None article_2_line: str | None = None + rule_side_1: str | None = None + debit_excluded_codes: tuple[str, ...] = () + credit_excluded_codes: tuple[str, ...] = () @property def preset_details(self) -> tuple[StatementType, int, int, str, str]: @@ -704,8 +711,8 @@ class IntercompanyMatrixService: 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)) + debit_codes = result.selection.debit_rule_codes + credit_codes = result.selection.credit_rule_codes 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) @@ -724,6 +731,9 @@ class IntercompanyMatrixService: 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, + rule_side_1=result.rule.side_1 if result.rule is not None else None, + debit_excluded_codes=result.selection.debit_excluded_codes, + credit_excluded_codes=result.selection.credit_excluded_codes, ) return result.matrix, info @@ -764,7 +774,16 @@ class IntercompanyMatrixService: 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 + 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( self, diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index a0e0fd2..689a5c3 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -57,17 +57,21 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: extra = [ ("Режим", mode_name), ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else all_accounts), - ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts), - ("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"), ] + if info.mode == "PRESET" and info.debit_excluded_codes: + extra.append(("кроме", ", ".join(info.debit_excluded_codes))) + extra.append(("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts)) + if info.mode == "PRESET" and info.credit_excluded_codes: + extra.append(("кроме", ", ".join(info.credit_excluded_codes))) + extra.append(("Показатель", "Сальдо" 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), + ("Статья А", article_1_line if info.rule_side_1 != "K" else article_2_line), + ("Статья Б", article_2_line if info.rule_side_1 != "K" else article_1_line), ("Версия правил", f"v{rule_version_no}"), ) ) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2719670..1f22f11 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -168,7 +168,10 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey _, 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.credit_account_codes == ("76",) + assert info.debit_excluded_codes == ("76.01",) + assert info.credit_excluded_codes == () + assert info.rule_side_1 == "D" assert info.measure == "turnover" assert info.rule_no == rule.rule_no assert info.rule_version_no == rule.version_no @@ -181,6 +184,10 @@ async def test_preset_credit_first_uses_second_side_for_debit(monkeypatch: pytes credit_child = _account(AccountKind.P, credit_parent.id) debit_parent = _account(AccountKind.A) 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] by_id, children = matrix._make_account_tree(accounts) rule = SimpleNamespace( @@ -208,6 +215,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_2_account_ids == {credit_parent.id} 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: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index e4a72d4..f934b2a 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -1,5 +1,6 @@ """XLSX-выгрузка шахматки из готового результата расчёта.""" +from dataclasses import replace from io import BytesIO from types import SimpleNamespace from unittest.mock import AsyncMock @@ -52,9 +53,35 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI 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, + rule_side_1="D" if mode == "PRESET" else None, ) +def test_preset_header_shows_rule_codes_exclusions_and_credit_first_articles() -> None: + matrix = IntercompanyMatrixOut(companies=[], cells=[]) + info = replace( + _info(mode="PRESET"), + debit_account_codes=("60.02", "76"), + credit_account_codes=("60.01", "76"), + debit_excluded_codes=("76.07.5", "76.07.9"), + credit_excluded_codes=("76.07.1",), + rule_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 range(7, 16)] == [ + ("Счета дебета", "60.02, 76"), + ("кроме", "76.07.5, 76.07.9"), + ("Счета кредита", "60.01, 76"), + ("кроме", "76.07.1"), + ("Показатель", "Оборот"), + ("Правило", "ББ № 6"), + ("Статья А", "1520"), + ("Статья Б", "1230"), + ("Версия правил", "v3"), + ] + + def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold() -> None: company_a = UUID("11111111-1111-1111-1111-111111111111") company_b = UUID("22222222-2222-2222-2222-222222222222") diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 61a9661..65abd28 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -280,8 +280,8 @@ SHALL считать исходные строки ОСВ пары с `account_i ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL -содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер -правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +содержать режим «Пресет», исходные коды счетов дебета и кредита из правила и отдельную строку «кроме» для исключений каждой стороны, если они есть, номер +правила, статьи А и Б по дебетовой и кредитовой сторонам соответственно (`article_1_line` / `article_2_line` при `side_1 = D`, наоборот при `side_1 = K`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам From fb04765ae9312e5c4f7c3361749ace1ae22daa57 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 5 Nov 2026 17:45:31 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidations/intercompany_matrix.py | 23 ++++-- api/src/services/intercompany_matrix.py | 79 ++++++++----------- .../services/intercompany_matrix_export.py | 22 +++--- api/tests/test_intercompany_matrix.py | 27 +++++-- api/tests/test_intercompany_matrix_export.py | 54 +++++++++---- openspec/specs/consolidated-osv/spec.md | 8 ++ 6 files changed, 126 insertions(+), 87 deletions(-) diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 5f41642..2f9c5e5 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -34,11 +34,20 @@ RESULT_ERRORS: dict[int | str, dict[str, Any]] = { "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_CONTENT: dict[int | str, dict[str, Any]] = { status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}} } EXPORT_RESPONSES = {**ERRORS, **XLSX_CONTENT} +PRESET_EXPORT_RESPONSES = {**PRESET_ERRORS, **XLSX_CONTENT} RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT} @@ -59,8 +68,6 @@ 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, @@ -75,11 +82,11 @@ async def _export_response( "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, + "rule_id": str(info.preset.id), + "rule_no": info.preset.rule_no, + "rule_version_no": info.preset.version_no, } - if info.mode == "PRESET" + if info.preset is not None else {} ), "cells": len(matrix.cells), @@ -172,7 +179,7 @@ async def export_intercompany_matrix_manual( "/{version_id}/intercompany-matrix/preset/{rule_id}", response_model=IntercompanyMatrixOut, dependencies=VIEW, - responses=ERRORS, + responses=PRESET_ERRORS, summary="Рассчитать шахматку по пресету", ) async def build_intercompany_matrix_preset( @@ -188,7 +195,7 @@ async def build_intercompany_matrix_preset( "/{version_id}/intercompany-matrix/preset/{rule_id}/export", response_class=Response, dependencies=VIEW, - responses=EXPORT_RESPONSES, + responses=PRESET_EXPORT_RESPONSES, summary="Скачать шахматку по пресету в XLSX", ) async def export_intercompany_matrix_preset( diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 2052745..34b9f76 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -5,7 +5,7 @@ from collections import defaultdict from collections.abc import Iterable, Sequence from dataclasses import dataclass from decimal import Decimal -from typing import Literal +from typing import Literal, cast from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -117,8 +117,21 @@ class VersionExportInfo: quarter: PeriodQuarter version_no: int - def _filename(self, prefix: str) -> str: - return f"{prefix}_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" + def _filename(self, prefix: str, suffix: str = "") -> str: + 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) @@ -127,40 +140,12 @@ class MatrixExportInfo(VersionExportInfo): 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 - rule_side_1: str | None = None - debit_excluded_codes: tuple[str, ...] = () - credit_excluded_codes: tuple[str, ...] = () - - @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 + preset: PresetRuleDetails | None = None @property def filename(self) -> str: - 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" + suffix = f"_rule-{self.preset.statement_type.value}-{self.preset.rule_no}" if self.preset else "" + return self._filename("intercompany-matrix", suffix) @dataclass(frozen=True) @@ -725,15 +710,19 @@ class IntercompanyMatrixService: 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, - rule_side_1=result.rule.side_1 if result.rule is not None else None, - debit_excluded_codes=result.selection.debit_excluded_codes, - credit_excluded_codes=result.selection.credit_excluded_codes, + preset=PresetRuleDetails( + id=result.rule.id, + rule_no=result.rule.rule_no, + version_no=result.rule.version_no, + statement_type=result.rule.statement_type, + 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 @@ -778,7 +767,9 @@ class IntercompanyMatrixService: return tuple(sorted(by_id[account_id].code for account_id in ids)) return MatrixSelection( - debit, credit, measure, + debit, + credit, + measure, debit_rule_codes=codes(debit_ids), credit_rule_codes=codes(credit_ids), debit_excluded_codes=codes(debit_excluded), diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 689a5c3..f8c953d 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -58,21 +58,21 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: ("Режим", mode_name), ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else all_accounts), ] - if info.mode == "PRESET" and info.debit_excluded_codes: - extra.append(("кроме", ", ".join(info.debit_excluded_codes))) + if info.preset is not None and info.preset.debit_excluded_codes: + extra.append(("кроме", ", ".join(info.preset.debit_excluded_codes))) extra.append(("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts)) - if info.mode == "PRESET" and info.credit_excluded_codes: - extra.append(("кроме", ", ".join(info.credit_excluded_codes))) + 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.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 "ОФР" + if info.preset is not None: + preset = info.preset + rule_type = "ББ" if preset.statement_type.value == "balance" else "ОФР" extra.extend( ( - ("Правило", f"{rule_type} № {rule_no}"), - ("Статья А", article_1_line if info.rule_side_1 != "K" else article_2_line), - ("Статья Б", article_2_line if info.rule_side_1 != "K" else article_1_line), - ("Версия правил", f"v{rule_version_no}"), + ("Правило", f"{rule_type} № {preset.rule_no}"), + ("Статья А", preset.article_1_line if preset.side_1 != "K" else preset.article_2_line), + ("Статья Б", preset.article_2_line if preset.side_1 != "K" else preset.article_1_line), + ("Версия правил", f"v{preset.version_no}"), ) ) return _write_details( diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 1f22f11..83c3578 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -89,6 +89,18 @@ def test_routes_are_separate_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")) + + async def test_build_pairs_uses_supplied_rows_and_prebuilt_osv(monkeypatch: pytest.MonkeyPatch) -> None: version_id, rule_id, company_a, company_b = (uuid.uuid4() for _ in range(4)) full_rows = [object()] @@ -169,14 +181,15 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey assert info.mode == "PRESET" assert info.debit_account_codes == ("76",) assert info.credit_account_codes == ("76",) - assert info.debit_excluded_codes == ("76.01",) - assert info.credit_excluded_codes == () - assert info.rule_side_1 == "D" + 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.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 + assert info.preset.rule_no == rule.rule_no + assert info.preset.version_no == rule.version_no + assert info.preset.article_1_line == rule.article_1_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: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index f934b2a..4952a18 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -27,7 +27,7 @@ from src.schemas.consolidations import ( IntercompanyResultArticleOut, 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 ( DIAGONAL_FILL, METRICS, @@ -47,38 +47,58 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI 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, - rule_side_1="D" if mode == "PRESET" else None, + preset=PresetRuleDetails( + id=rule_id or UUID(int=0), + rule_no=6, + version_no=3, + statement_type=StatementType.balance, + article_1_line="1230", + article_2_line="1520", + side_1="D", + ) + if mode == "PRESET" + else None, ) -def test_preset_header_shows_rule_codes_exclusions_and_credit_first_articles() -> 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", "76"), - credit_account_codes=("60.01", "76"), - debit_excluded_codes=("76.07.5", "76.07.9"), - credit_excluded_codes=("76.07.1",), - rule_side_1="K", + 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, 76"), + ("Счета дебета", "60.02, 60.22, 60.32, 62.01, 62.03, 62.21, 62.31, 76"), ("кроме", "76.07.5, 76.07.9"), - ("Счета кредита", "60.01, 76"), + ("Счета кредита", "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"), - ("Версия правил", "v3"), ] diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 65abd28..b815c74 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -284,6 +284,14 @@ SHALL считать исходные строки ОСВ пары с `account_i правила, статьи А и Б по дебетовой и кредитовой сторонам соответственно (`article_1_line` / `article_2_line` при `side_1 = D`, наоборот при `side_1 = K`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). +#### Scenario: Правило № 6 с исключениями +- **WHEN** пользователь выгружает пресет правила № 6 из seed-набора Прил. 3 +- **THEN** шапка показывает исходные коды счетов сторон правила, в отдельной строке дебета «кроме 76.07.5, 76.07.9», а в отдельной строке кредита — «кроме 76.07.1»; раскрытые субсчета счёта 76 в шапку не добавляются + +#### Scenario: Кредитная первая сторона правила +- **WHEN** пользователь выгружает пресет с `side_1 = K` +- **THEN** «Статья А» содержит `article_2_line`, «Статья Б» — `article_1_line` + #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` - **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` From e9a4f8e8a86ab22d7d6c8d21fa651470e0739d41 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 15:17:08 +0400 Subject: [PATCH 3/5] openspec: change intercompany-matrix-preset-header (AURORA-1761, PR #223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шапка XLSX пресета: коды правила без раскрытия, строки «кроме», статьи А/Б по сторонам. Сценарий правила № 6 — без перечня кодов (после AURORA-2034 исключения 76 на обеих сторонах), решения в design.md. Co-Authored-By: Claude Opus 5.5 --- .../design.md | 11 ++++++ .../proposal.md | 34 +++++++++++++++++++ .../specs/consolidated-osv/spec.md | 29 ++++++++++++++++ .../tasks.md | 10 ++++++ 4 files changed, 84 insertions(+) create mode 100644 openspec/changes/intercompany-matrix-preset-header/design.md create mode 100644 openspec/changes/intercompany-matrix-preset-header/proposal.md create mode 100644 openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md create mode 100644 openspec/changes/intercompany-matrix-preset-header/tasks.md diff --git a/openspec/changes/intercompany-matrix-preset-header/design.md b/openspec/changes/intercompany-matrix-preset-header/design.md new file mode 100644 index 0000000..a62afc3 --- /dev/null +++ b/openspec/changes/intercompany-matrix-preset-header/design.md @@ -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`), на шапку пресета не влияет. diff --git a/openspec/changes/intercompany-matrix-preset-header/proposal.md b/openspec/changes/intercompany-matrix-preset-header/proposal.md new file mode 100644 index 0000000..f532616 --- /dev/null +++ b/openspec/changes/intercompany-matrix-preset-header/proposal.md @@ -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` по-прежнему + сначала считается свод. diff --git a/openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..065ac5e --- /dev/null +++ b/openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: XLSX шахматки пресета +`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт +пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.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-.xlsx` + +#### Scenario: Правило не найдено +- **WHEN** `rule_id` не существует +- **THEN** 404, файл не отдаётся, событие аудита не пишется diff --git a/openspec/changes/intercompany-matrix-preset-header/tasks.md b/openspec/changes/intercompany-matrix-preset-header/tasks.md new file mode 100644 index 0000000..3c1266a --- /dev/null +++ b/openspec/changes/intercompany-matrix-preset-header/tasks.md @@ -0,0 +1,10 @@ +# 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 From 6b948debc059b0d5712f2dd6dd75db408097b4bb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 01:37:06 +0400 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20XL?= =?UTF-8?q?SX=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82=D0=B0=20=D1=81=D0=BE?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=D0=BC=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=BC=D0=B5=C2=BB=20=D0=B2=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=82=D0=B5=D1=81=D1=82=D0=B5=20(AURORA-1761,?= =?UTF-8?q?=20PR=20#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/test_api_intercompany_matrix.py | 37 ++++++++++++++----- .../tasks.md | 2 + 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 44050cd..773b3cf 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -13,7 +13,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.consolidation import ConsolidationDocument, ConsolidationVersion 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}") +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( session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None ) -> 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 f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"]) sheet = load_workbook(BytesIO(exported.content))["Шахматка"] + codes = await _rule_codes(seeded, rule) 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.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") + assert "76.07.1" in sheet["B10"].value + assert sheet["B11"].value == "Сальдо" + assert sheet["B12"].value == f"ББ № {rule.rule_no}" + assert sheet["B13"].value == rule.article_1_line + assert sheet["B14"].value == rule.article_2_line + assert sheet["B15"].value == f"v{rule.version_no}" + assert Decimal(str(sheet["D18"].value)) == Decimal("100") + assert Decimal(str(sheet["D19"].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) diff --git a/openspec/changes/intercompany-matrix-preset-header/tasks.md b/openspec/changes/intercompany-matrix-preset-header/tasks.md index 3c1266a..5fef465 100644 --- a/openspec/changes/intercompany-matrix-preset-header/tasks.md +++ b/openspec/changes/intercompany-matrix-preset-header/tasks.md @@ -8,3 +8,5 @@ - [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, данные матрицы сдвинуты на две строки) From 2adacd07e107841ba71ddb5292695ea49f5ec314 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 12 Nov 2026 10:21:35 +0400 Subject: [PATCH 5/5] =?UTF-8?q?openspec:=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2?= =?UTF-8?q?=20intercompany-matrix-preset-header=20(AURORA-1761,=20PR=20#22?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../design.md | 0 .../proposal.md | 0 .../specs/consolidated-osv/spec.md | 0 .../tasks.md | 0 openspec/specs/consolidated-osv/spec.md | 17 +++++++++++++++-- 5 files changed, 15 insertions(+), 2 deletions(-) rename openspec/changes/{intercompany-matrix-preset-header => archive/2026-11-12-intercompany-matrix-preset-header}/design.md (100%) rename openspec/changes/{intercompany-matrix-preset-header => archive/2026-11-12-intercompany-matrix-preset-header}/proposal.md (100%) rename openspec/changes/{intercompany-matrix-preset-header => archive/2026-11-12-intercompany-matrix-preset-header}/specs/consolidated-osv/spec.md (100%) rename openspec/changes/{intercompany-matrix-preset-header => archive/2026-11-12-intercompany-matrix-preset-header}/tasks.md (100%) diff --git a/openspec/changes/intercompany-matrix-preset-header/design.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/design.md similarity index 100% rename from openspec/changes/intercompany-matrix-preset-header/design.md rename to openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/design.md diff --git a/openspec/changes/intercompany-matrix-preset-header/proposal.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/proposal.md similarity index 100% rename from openspec/changes/intercompany-matrix-preset-header/proposal.md rename to openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/proposal.md diff --git a/openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/specs/consolidated-osv/spec.md similarity index 100% rename from openspec/changes/intercompany-matrix-preset-header/specs/consolidated-osv/spec.md rename to openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/specs/consolidated-osv/spec.md diff --git a/openspec/changes/intercompany-matrix-preset-header/tasks.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/tasks.md similarity index 100% rename from openspec/changes/intercompany-matrix-preset-header/tasks.md rename to openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/tasks.md diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index b5bce9d..46257a3 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -307,10 +307,23 @@ SHALL считать исходные строки ОСВ пары с `account_i ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL -содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер -правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +содержать режим «Пресет», коды счетов дебета и кредита так, как они заданы в правиле (без раскрытия по иерархии), под +каждой стороной — строку «кроме» с исключёнными кодами этой стороны, если исключения есть, номер правила, статьи А и Б, +фактическую версию правил и строку «Показатель» со значением «Сальдо» для `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-.xlsx`