diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 536e18b..2f8c88f 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: IntercompanyMatrixSelectionIn | 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, @@ -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 [], **( { - "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), @@ -137,7 +144,7 @@ async def export_intercompany_matrix( "/{version_id}/intercompany-matrix/preset/{rule_id}", response_model=IntercompanyMatrixOut, dependencies=VIEW, - responses=ERRORS, + responses=PRESET_ERRORS, summary="Рассчитать шахматку по пресету", ) async def build_intercompany_matrix_preset( @@ -153,7 +160,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 0f9f23d..9f14e46 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 @@ -110,6 +110,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) @@ -133,8 +137,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) @@ -143,37 +160,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 - - @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) @@ -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" ) 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) @@ -744,12 +736,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, + 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 @@ -790,7 +789,18 @@ 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 2a22051..36eaa0f 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -57,18 +57,22 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: extra = [ ("Режим", mode_name), ("Счета дебета", ", ".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": - 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 and info.preset.debit_excluded_codes: + extra.append(("кроме", ", ".join(info.preset.debit_excluded_codes))) + 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( ( - ("Правило", f"{rule_type} № {rule_no}"), - ("Статья А", article_1_line), - ("Статья Б", article_2_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_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/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2ec786c..f8ad4be 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -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: company_a, company_b, company_c = (uuid.uuid4() for _ in range(3)) 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()) 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.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: @@ -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) 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( @@ -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_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 a9b764e..a2a172f 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -28,7 +28,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, @@ -49,15 +49,61 @@ 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, + 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_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: company_a = UUID("11111111-1111-1111-1111-111111111111") company_b = UUID("22222222-2222-2222-2222-222222222222") diff --git a/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/design.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/design.md new file mode 100644 index 0000000..a62afc3 --- /dev/null +++ b/openspec/changes/archive/2026-11-12-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/archive/2026-11-12-intercompany-matrix-preset-header/proposal.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/proposal.md new file mode 100644 index 0000000..f532616 --- /dev/null +++ b/openspec/changes/archive/2026-11-12-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/archive/2026-11-12-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 new file mode 100644 index 0000000..065ac5e --- /dev/null +++ b/openspec/changes/archive/2026-11-12-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/archive/2026-11-12-intercompany-matrix-preset-header/tasks.md b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/tasks.md new file mode 100644 index 0000000..5fef465 --- /dev/null +++ b/openspec/changes/archive/2026-11-12-intercompany-matrix-preset-header/tasks.md @@ -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, данные матрицы сдвинуты на две строки) 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`