diff --git a/README.md b/README.md index f40532f..928bfcc 100644 --- a/README.md +++ b/README.md @@ -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-.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель», audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический `rule_version_no`. Открытые вопросы по пресетам — `openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта — diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 29b8cac..52f2742 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -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 diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index a5c50bb..15111b3 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -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() diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index c56f9cc..22e15b8 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -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) diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2132df7..aaef320 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -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: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 0a53491..6716573 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -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, diff --git a/openspec/changes/intercompany-matrix-presets/design.md b/openspec/changes/intercompany-matrix-presets/design.md index cd983a2..4ef47b2 100644 --- a/openspec/changes/intercompany-matrix-presets/design.md +++ b/openspec/changes/intercompany-matrix-presets/design.md @@ -24,8 +24,9 @@ change не входит. Шахматка — чистая функция от 5. `unpaired-osv` строится из `one_sided`-ячеек шахматки пресета (`osv_entry_ids_*`) и возвращает строки сводной ОСВ, содержащие эти записи. 6. `GET …/elimination-rules` отдаёт объект с основанием выбора версии; `as_of` не вводится. -7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`, в шапке «Режим = Пресет», - коды счетов сторон (отсортированы) и строка «Показатель» (Сальдо/Оборот) для всех режимов. +7. xlsx пресета: имя `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.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. Цена diff --git a/openspec/changes/intercompany-matrix-presets/proposal.md b/openspec/changes/intercompany-matrix-presets/proposal.md index 25ab009..6b4f00d 100644 --- a/openspec/changes/intercompany-matrix-presets/proposal.md +++ b/openspec/changes/intercompany-matrix-presets/proposal.md @@ -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-`, в шапке строка «Показатель»; audit `EXPORT` пишет `mode` +- xlsx: для PRESET имя файла с `_rule-balance-`, в шапке номер, статьи, версия правила и «Показатель»; audit `EXPORT` пишет `mode` `ALL`/`MANUAL`/`PRESET`, а для PRESET также `rule_id`, `rule_no` и фактический `rule_version_no`. ## Capabilities diff --git a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md index 334b71c..3adf433 100644 --- a/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-presets/specs/consolidated-osv/spec.md @@ -52,14 +52,14 @@ ### Requirement: XLSX шахматки пресета `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL возвращать XLSX с теми же строками, что JSON-расчёт -пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_preset-.xlsx`. Шапка SHALL -содержать режим «Пресет», коды счетов дебета и кредита правила и строку «Показатель» со значением «Сальдо» для -`closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение +пресета. Имя файла SHALL иметь вид `intercompany-matrix_<ГК>_<год>-<квартал>_v_rule-balance-.xlsx`. Шапка SHALL +содержать режим «Пресет», коды счетов дебета и кредита правила, номер правила, статьи А и Б, фактическую версию правил +и строку «Показатель» со значением «Сальдо» для `closing` или «Оборот» для `turnover`. Строка «Показатель» SHALL присутствовать и в выгрузках ALL и MANUAL (значение «Сальдо»). #### Scenario: Выгрузка по оборотам - **WHEN** пользователь выгружает пресет с `measure = turnover` -- **THEN** шапка содержит «Режим — Пресет» и «Показатель — Оборот», имя файла заканчивается на `_preset-.xlsx` +- **THEN** шапка содержит «Режим — Пресет», «Показатель — Оборот», номер, статьи и версию правила; имя файла заканчивается на `_rule-balance-.xlsx` #### Scenario: Правило не найдено - **WHEN** `rule_id` не существует