[AURORA-2014] - Расхождения с макетами в карточке консолидации, справочнике ГК и окне корректировки #245
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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`), на шапку пресета не влияет.
|
||||
@ -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` по-прежнему
|
||||
сначала считается свод.
|
||||
@ -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, файл не отдаётся, событие аудита не пишется
|
||||
@ -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, данные матрицы сдвинуты на две строки)
|
||||
@ -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`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user