Merge branch 'main' into fix/2006-goodwill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 11:17:57 +04:00
commit eed4fbb298
11 changed files with 288 additions and 80 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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,13 +49,59 @@ 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:

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, данные матрицы сдвинуты на две строки)

View File

@ -307,10 +307,23 @@ SHALL считать исходные строки ОСВ пары с `account_i
### Requirement: XLSX шахматки пресета
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
содержать режим «Пресет», коды счетов дебета и кредита (раскрытые по иерархии, с учётом исключений правила), номер
правила, статьи А и Б (`article_1_line` / `article_2_line`), фактическую версию правил и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
содержать режим «Пресет», коды счетов дебета и кредита так, как они заданы в правиле (без раскрытия по иерархии), под
каждой стороной — строку «кроме» с исключёнными кодами этой стороны, если исключения есть, номер правила, статьи А и Б,
фактическую версию правил и строку «Показатель» со значением «Сальдо» для `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`