feat(matrix): понятное имя и реквизиты правила в XLSX пресета

This commit is contained in:
PotapovaA 2026-10-21 18:59:25 +03:00
parent b078933428
commit 9e39c4d426
9 changed files with 84 additions and 30 deletions

View File

@ -875,7 +875,8 @@ API: список счетов `GET /directories/accounts`, правила ве
`…/preset/{rule_id}/unpaired-osv`.
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): при незаданной
версии правил — открытые правила (`valid_to IS NULL`) с максимальным `version_no`; ответ сообщает этот номер. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. В шапке xlsx — строка «Показатель»,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
`_rule-balance-<rule_no>.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический
`rule_version_no`. Открытые вопросы по пресетам —
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —

View File

@ -103,10 +103,32 @@ class MatrixExportInfo:
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]:
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
@property
def filename(self) -> str:
preset = f"_preset-{self.rule_id}" if self.rule_id is not None else ""
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"
@ -400,6 +422,9 @@ class IntercompanyMatrixService:
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,
)
return result.matrix, info

View File

@ -12,8 +12,6 @@ from src.services.intercompany_matrix import MatrixExportInfo
from src.services.osv_export import MONEY_FORMAT
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
TABLE_HEADER_ROW = 10
DATA_START_ROW = TABLE_HEADER_ROW + 1
PERCENT_FORMAT = '0.00"%"'
THRESHOLD_FILL = PatternFill("solid", fgColor="FCE8E6")
THRESHOLD_FONT = Font(color="9C0006", bold=True)
@ -33,10 +31,10 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non
sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА")
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int:
_text(sheet, 1, 1, "Шахматка внутригрупповых операций")
mode_name = {"ALL": "Все счета", "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode]
details = (
details = [
("ГК", info.group_name),
("Период", f"{info.year}-{info.quarter.value}"),
("Версия", f"v{info.version_no}"),
@ -45,11 +43,23 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else "Все счета"),
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else "Все счета"),
("Показатель", "Сальдо" 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 "ОФР"
details.extend(
(
("Правило", f"{rule_type} № {rule_no}"),
("Статья А", article_1_line),
("Статья Б", article_2_line),
("Версия правил", f"v{rule_version_no}"),
)
)
for row, (label, value) in enumerate(details, start=2):
_text(sheet, row, 1, label)
_text(sheet, row, 2, value)
sheet.cell(row, 1).font = Font(bold=True)
return len(details) + 2
def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None:
@ -65,24 +75,25 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
sheet = workbook.active
assert sheet is not None
sheet.title = "Шахматка"
_write_context(sheet, info)
_text(sheet, TABLE_HEADER_ROW, 1, "Дебет ↓ / Кредит →")
_text(sheet, TABLE_HEADER_ROW, 2, "Показатель")
table_header_row = _write_context(sheet, info)
data_start_row = table_header_row + 1
_text(sheet, table_header_row, 1, "Дебет ↓ / Кредит →")
_text(sheet, table_header_row, 2, "Показатель")
sheet.column_dimensions["A"].width = 32
sheet.column_dimensions["B"].width = 40
for column, company in enumerate(matrix.companies, start=3):
_text(sheet, TABLE_HEADER_ROW, column, company.name)
_text(sheet, table_header_row, column, company.name)
sheet.column_dimensions[get_column_letter(column)].width = 26
sheet.row_dimensions[TABLE_HEADER_ROW].height = 36
for header in sheet[TABLE_HEADER_ROW]:
sheet.row_dimensions[table_header_row].height = 36
for header in sheet[table_header_row]:
header.font = Font(bold=True)
header.fill = HEADER_FILL
header.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cells = {(cell.company_a_id, cell.company_b_id): cell for cell in matrix.cells}
for company_index, debit_company in enumerate(matrix.companies):
start_row = DATA_START_ROW + company_index * len(METRICS)
start_row = data_start_row + company_index * len(METRICS)
end_row = start_row + len(METRICS) - 1
sheet.merge_cells(start_row=start_row, start_column=1, end_row=end_row, end_column=1)
_text(sheet, start_row, 1, debit_company.name)
@ -113,7 +124,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
marked.fill = THRESHOLD_FILL
marked.font = THRESHOLD_FONT
sheet.freeze_panes = f"C{DATA_START_ROW}"
sheet.freeze_panes = f"C{data_start_row}"
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()

View File

@ -166,14 +166,18 @@ async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFact
f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}/export"
)
assert exported.status_code == OK, exported.text
assert f"preset-{rule.id}" in unquote(exported.headers["content-disposition"])
assert f"_rule-balance-{rule.rule_no}.xlsx" in unquote(exported.headers["content-disposition"])
sheet = load_workbook(BytesIO(exported.content))["Шахматка"]
assert sheet["B6"].value == "Пресет"
assert "76.07.5" not in sheet["B7"].value
assert "76.07.1" not in sheet["B8"].value
assert sheet["B9"].value == "Сальдо"
assert Decimal(str(sheet["D12"].value)) == Decimal("100")
assert Decimal(str(sheet["D13"].value)) == Decimal("90")
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")
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

@ -131,6 +131,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
id=uuid.uuid4(),
rule_no=6,
statement_type=StatementType.balance,
article_1_line="1230",
article_2_line="1520",
version_no=1,
side_1="D",
measure="turnover",
@ -168,6 +170,8 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
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
async def test_preset_rejects_income_rule_before_selecting_accounts(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -15,7 +15,7 @@ from src.api.endpoints.consolidations.intercompany_matrix import (
export_intercompany_matrix_preset,
router,
)
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
from src.models.enums import AuditAction, AuditSection, PeriodQuarter, StatementType
from src.schemas.consolidations import (
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
@ -39,6 +39,9 @@ def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportI
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,
)
@ -194,6 +197,15 @@ async def test_export_routes_use_matrix_calculation() -> None:
assert preset_sheet["B7"].value == "62.01"
assert preset_sheet["B8"].value == "60.01"
assert preset_sheet["B9"].value == "Оборот"
assert [(preset_sheet.cell(row, 1).value, preset_sheet.cell(row, 2).value) for row in range(10, 14)] == [
("Правило", "ББ № 6"),
("Статья А", "1230"),
("Статья Б", "1520"),
("Версия правил", "v3"),
]
assert preset_sheet["A14"].value == "Дебет ↓ / Кредит →"
assert preset_sheet.freeze_panes == "C15"
assert _info(mode="PRESET", rule_id=account_id).filename.endswith("_rule-balance-6.xlsx")
assert [call.kwargs for call in audit.record.await_args_list] == [
{
"action": AuditAction.EXPORT,

View File

@ -24,8 +24,9 @@ change не входит. Шахматка — чистая функция от
5. `unpaired-osv` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ,
содержащие эти записи.
6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится.
7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_preset-<rule_id>.xlsx`, в шапке «Режим = Пресет»,
коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов.
7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`, в шапке «Режим = Пресет»,
номер правила, статьи сторон, фактическая версия правил, коды счетов сторон (отсортированы) и строка «Показатель»
(Сальдо/Оборот) для всех режимов.
## Решения владельца (2026-10-21, ревью PR #141)
@ -49,10 +50,6 @@ change не входит. Шахматка — чистая функция от
После выноса п. 3–4 автором соответствующие требования дельты `consolidated-osv` удаляются из change.
## Known issues
1. Имя файла содержит UUID правила, шапка xlsx не содержит номер правила и версию.
## Decisions made without discussion
- 2026-10-21. Change пишет ревьюер в ветке автора по решению владельца. Обоснование: автор не оформила OpenSpec. Цена

View File

@ -14,7 +14,7 @@
- `GET …/elimination-rules` возвращает объект `{version_no, is_version_fixed, rules}` вместо массива; у правила есть
`id`. Для версии без зафиксированного `elimination_rules_version` отдаются открытые правила (`valid_to IS NULL`)
с максимальным `version_no`; ответ содержит этот номер.
- xlsx: для PRESET имя файла с `_preset-<rule_id>`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode`
- xlsx: для PRESET имя файла с `_rule-balance-<rule_no>`, в шапке номер, статьи, версия правила и «Показатель»; audit `EXPORT` пишет `mode`
`ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`.
## Capabilities

View File

@ -52,14 +52,14 @@
### Requirement: XLSX шахматки пресета
`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_preset-<rule_id>.xlsx`. Шапка SHALL
содержать режим «Пресет», коды счетов дебета и кредита правила и строку «Показатель» со значением «Сальдо» для
`closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v<n>_rule-balance-<rule_no>.xlsx`. Шапка SHALL
содержать режим «Пресет», коды счетов дебета и кредита правила, номер правила, статьи А и Б, фактическую версию правил
и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение
«Сальдо»).
#### Scenario: Выгрузка по оборотам
- **WHEN** пользователь выгружает пресет с `measure = turnover`
- **THEN** шапка содержит «Режим — Пресет» и «Показатель — Оборот», имя файла заканчивается на `_preset-<rule_id>.xlsx`
- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-<rule_no>.xlsx`
#### Scenario: Правило не найдено
- **WHEN** `rule_id` не существует