убраны лишние проверки

This commit is contained in:
PotapovaA 2026-11-05 17:45:31 +03:00
parent e5b998df20
commit fb04765ae9
6 changed files with 126 additions and 87 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: 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(

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
@ -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,16 +710,20 @@ 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,
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
async def _resolve_preset_selection(
@ -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),

View File

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

View File

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

View File

@ -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_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",),
rule_side_1="K",
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"),
]

View File

@ -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-<rule_no>.xlsx`