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`