diff --git a/README.md b/README.md index e4e9f66..4867f66 100644 --- a/README.md +++ b/README.md @@ -870,7 +870,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица `GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица -`GET …/intercompany-matrix/result-table`. Контракт для фронта — +`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` +(аудит `EXPORT`, `report = "intercompany_result_table"`). Контракт для фронта — `../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях `openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`. Расчёт читает сводную ОСВ версии, результат не хранится. diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 2fd695a..be82051 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -14,7 +14,7 @@ from src.models.enums import AuditAction, AuditSection from src.schemas.common import ErrorDetail from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut from src.services.intercompany_matrix import MatrixExportInfo -from src.services.intercompany_matrix_export import build_matrix_workbook +from src.services.intercompany_matrix_export import build_matrix_workbook, build_result_table_workbook router = APIRouter(prefix="/consolidations/versions") @@ -23,19 +23,26 @@ ERRORS: dict[int | str, dict[str, Any]] = { status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"}, status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"}, } -XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" -EXPORT_RESPONSES = { +RESULT_ERRORS: dict[int | str, dict[str, Any]] = { **ERRORS, - status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "model": ErrorDetail, + "description": "В плане счетов нет счетов статей результирующей таблицы", + }, } +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} +RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT} -async def _xlsx_response(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> Response: - content = await run_in_threadpool(build_matrix_workbook, matrix, info) +def _xlsx_response(content: bytes, filename: str, *, ascii_fallback: str) -> Response: return Response( content=content, media_type=XLSX_MEDIA_TYPE, - headers={"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-matrix.xlsx")}, + headers={"Content-Disposition": content_disposition(filename, ascii_fallback=ascii_fallback)}, ) @@ -47,7 +54,8 @@ async def _export_response( *, selection: IntercompanyMatrixManualIn | None = None, ) -> Response: - response = await _xlsx_response(matrix, info) + content = await run_in_threadpool(build_matrix_workbook, matrix, info) + response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx") await audit.record( action=AuditAction.EXPORT, section=AuditSection.EXPORT, @@ -149,7 +157,7 @@ async def export_intercompany_matrix_manual( "/{version_id}/intercompany-matrix/result-table", response_model=IntercompanyResultTableOut, dependencies=VIEW, - responses=ERRORS, + responses=RESULT_ERRORS, summary="Результирующая таблица ВГО", ) async def get_intercompany_result_table( @@ -158,3 +166,29 @@ async def get_intercompany_result_table( ) -> IntercompanyResultTableOut: """Пять фиксированных статей: суммы по компаниям и итоги по группе.""" return await service.build_result_table(version_id) + + +@router.get( + "/{version_id}/intercompany-matrix/result-table/export", + response_class=Response, + dependencies=VIEW, + responses=RESULT_EXPORT_RESPONSES, + summary="Скачать результирующую таблицу ВГО в XLSX", +) +async def export_intercompany_result_table( + version_id: uuid.UUID, + service: IntercompanyMatrixServiceDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> Response: + result, info = await service.build_result_export(version_id) + content = await run_in_threadpool(build_result_table_workbook, result, info) + response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-result-table.xlsx") + await audit.record( + action=AuditAction.EXPORT, + section=AuditSection.EXPORT, + object_type="consolidation_version", + object_id=version_id, + consolidation_version_id=version_id, + after_value={"report": "intercompany_result_table", "articles": len(result.articles)}, + ) + return response diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index f14db8c..bbc0014 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -76,18 +76,41 @@ class MatrixSelection: @dataclass(frozen=True) -class MatrixExportInfo: +class VersionExportInfo: + """Реквизиты версии в шапке и имени книги.""" + group_name: str year: int 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" + + +@dataclass(frozen=True) +class MatrixExportInfo(VersionExportInfo): mode: Literal["ALL", "MANUAL"] debit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...] @property def filename(self) -> str: - return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" + return self._filename("intercompany-matrix") + + +@dataclass(frozen=True) +class ResultTableExportInfo(VersionExportInfo): + @property + def filename(self) -> str: + return self._filename("intercompany-result-table") + + +@dataclass(frozen=True) +class ResultTableBuild: + table: IntercompanyResultTableOut + version: ConsolidationVersion + consolidation: Consolidation def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: @@ -300,10 +323,34 @@ class IntercompanyMatrixService: async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut: """Статичная таблица по пяти статьям""" - consolidated, _, _, members = await self._osv_service.build_with_members(version_id) + return (await self._build_result_table(version_id)).table + + async def build_result_export( + self, version_id: uuid.UUID + ) -> tuple[IntercompanyResultTableOut, ResultTableExportInfo]: + """Таблица и реквизиты книги из одного расчёта свода.""" + built = await self._build_result_table(version_id) + info = ResultTableExportInfo( + group_name=await self._group_name(built.consolidation), + year=built.consolidation.year, + quarter=built.consolidation.quarter, + version_no=built.version.version_no, + ) + return built.table, info + + async def _group_name(self, consolidation: Consolidation) -> str: + return ( + await self._session.execute( + select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) + ) + ).scalar_one() + + async def _build_result_table(self, version_id: uuid.UUID) -> ResultTableBuild: + consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id) accounts = await directory_repo.list_accounts(self._session) companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()] - return calculate_result_table(consolidated.rows, companies, accounts) + result = calculate_result_table(consolidated.rows, companies, accounts) + return ResultTableBuild(result, version, consolidation) async def build_accounts( self, @@ -331,14 +378,9 @@ class IntercompanyMatrixService: matrix, version, consolidation, accounts = await self._build_accounts( version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids ) - group_name = ( - await self._session.execute( - select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id) - ) - ).scalar_one() by_id = {account.id: account.code for account in accounts} info = MatrixExportInfo( - group_name=group_name, + group_name=await self._group_name(consolidation), year=consolidation.year, quarter=consolidation.quarter, version_no=version.version_no, diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 081be3f..5e91e55 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -7,8 +7,8 @@ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter from openpyxl.worksheet.worksheet import Worksheet -from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut -from src.services.intercompany_matrix import MatrixExportInfo +from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut +from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo, VersionExportInfo from src.services.osv_export import MONEY_FORMAT METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %") @@ -33,16 +33,17 @@ 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: - _text(sheet, 1, 1, "Шахматка внутригрупповых операций") +def _write_details( + sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = () +) -> None: + """Заголовок и строки «реквизит — значение» над таблицей книги.""" + _text(sheet, 1, 1, title) details = ( ("ГК", info.group_name), ("Период", f"{info.year}-{info.quarter.value}"), ("Версия", f"v{info.version_no}"), - ("Единицы", "руб.; расхождение — %"), - ("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"), - ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"), - ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"), + ("Единицы", units), + *extra, ) for row, (label, value) in enumerate(details, start=2): _text(sheet, row, 1, label) @@ -50,6 +51,20 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: sheet.cell(row, 1).font = Font(bold=True) +def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None: + _write_details( + sheet, + "Шахматка внутригрупповых операций", + info, + "руб.; расхождение — %", + ( + ("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"), + ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"), + ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"), + ), + ) + + def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None: for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)): sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT @@ -115,3 +130,41 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) buffer = BytesIO() workbook.save(buffer) return buffer.getvalue() + + +def build_result_table_workbook(result: IntercompanyResultTableOut, info: ResultTableExportInfo) -> bytes: + workbook = Workbook() + sheet = workbook.active + assert sheet is not None + sheet.title = "Результирующая таблица" + _write_details(sheet, "Результирующая таблица ВГО", info, "руб.") + + header_row = 7 + _text(sheet, header_row, 1, "Статья ВГО") + sheet.column_dimensions["A"].width = 42 + for column, company in enumerate(result.companies, start=2): + _text(sheet, header_row, column, company.name) + sheet.column_dimensions[get_column_letter(column)].width = 26 + total_column = len(result.companies) + 2 + _text(sheet, header_row, total_column, "Итого по группе") + sheet.column_dimensions[get_column_letter(total_column)].width = 26 + sheet.row_dimensions[header_row].height = 36 + for column in range(1, total_column + 1): + cell = sheet.cell(header_row, column) + cell.font = Font(bold=True) + cell.fill = HEADER_FILL + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + for row, article in enumerate(result.articles, start=header_row + 1): + _text(sheet, row, 1, article.name) + amounts = {amount.company_id: amount.amount for amount in article.amounts} + for column, company in enumerate(result.companies, start=2): + sheet.cell(row, column, amounts[company.id]).number_format = MONEY_FORMAT + total = sheet.cell(row, total_column, article.group_total) + total.number_format = MONEY_FORMAT + total.font = Font(bold=True) + + sheet.freeze_panes = "B8" + 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 0017786..950e7a7 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -27,7 +27,7 @@ from tests.factories import make_company_group, make_confirmed_osv, make_consoli pytestmark = pytest.mark.integration OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 KA_RF = "ka_rf@armka.local" -ROUTES = ("all", "manual", "result-table", "all/export", "manual/export") +ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export") @dataclass @@ -220,15 +220,39 @@ async def test_invalid_account_selection( assert response.json()["detail"]["code"] == code -async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: +@pytest.mark.parametrize("suffix", ["result-table", "result-table/export"]) +async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: world = await _world(seeded) # Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков. world.accounts["58.03"].code = "58.03-missing" await seeded.flush() - response = await _request(await auth_client(KA_RF), world.version.id, "result-table", world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) assert response.status_code == UNPROCESSABLE, response.text assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING" assert "58.03" in response.json()["detail"]["message"] + assert not (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).all() + + +async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + await _source(seeded, world, credit=False, amount="100.25") + client = await auth_client(KA_RF) + result = await _request(client, world.version.id, "result-table", world.selection) + export = await _request(client, world.version.id, "result-table/export", world.selection) + assert result.status_code == export.status_code == OK + data = result.json() + sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"] + assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [ + company["name"] for company in data["companies"] + ] + for row, article in enumerate(data["articles"], start=8): + assert sheet.cell(row, 1).value == article["name"] + amounts = {item["company_id"]: Decimal(item["amount"]) for item in article["amounts"]} + for col, company in enumerate(data["companies"], start=2): + assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]] + assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"]) + event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() + assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])} @pytest.mark.parametrize("mode", ["all", "manual"]) diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 20fcc97..9b43dbd 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -12,6 +12,7 @@ from openpyxl import load_workbook from src.api.endpoints.consolidations.intercompany_matrix import ( export_intercompany_matrix_all, export_intercompany_matrix_manual, + export_intercompany_result_table, router, ) from src.models.enums import AuditAction, AuditSection, PeriodQuarter @@ -20,9 +21,18 @@ from src.schemas.consolidations import ( IntercompanyMatrixCompanyOut, IntercompanyMatrixManualIn, IntercompanyMatrixOut, + IntercompanyResultAmountOut, + IntercompanyResultArticleOut, + IntercompanyResultTableOut, +) +from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo +from src.services.intercompany_matrix_export import ( + DIAGONAL_FILL, + METRICS, + THRESHOLD_FILL, + build_matrix_workbook, + build_result_table_workbook, ) -from src.services.intercompany_matrix import MatrixExportInfo -from src.services.intercompany_matrix_export import DIAGONAL_FILL, METRICS, THRESHOLD_FILL, build_matrix_workbook def _info(*, mode: str = "MANUAL") -> MatrixExportInfo: @@ -229,3 +239,85 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon with pytest.raises(ValueError, match="xlsx failed"): await export_intercompany_matrix_all(version_id, service, audit) audit.record.assert_not_awaited() + + +async def test_result_table_export_matches_result_and_records_audit() -> None: + version_id = UUID("11111111-1111-1111-1111-111111111111") + other_id = UUID("22222222-2222-2222-2222-222222222222") + result = IntercompanyResultTableOut( + companies=[ + IntercompanyMatrixCompanyOut(id=version_id, name="=Компания А"), + IntercompanyMatrixCompanyOut(id=other_id, name="Компания Б"), + ], + articles=[ + IntercompanyResultArticleOut( + code="receivables", + name="Дебиторская задолженность", + amounts=[ + IntercompanyResultAmountOut(company_id=version_id, amount="100.25"), + IntercompanyResultAmountOut(company_id=other_id, amount="20.00"), + ], + group_total="120.25", + ) + ], + ) + info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2) + service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info))) + audit = SimpleNamespace(record=AsyncMock()) + + response = await export_intercompany_result_table(version_id, service, audit) + service.build_result_export.assert_awaited_once_with(version_id) + assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + assert unquote(response.headers["content-disposition"].split("filename*=UTF-8''", 1)[1]) == info.filename + sheet = load_workbook(BytesIO(response.body))["Результирующая таблица"] + assert [sheet[f"B{row}"].value for row in range(2, 6)] == [info.group_name, "2024-Q3", "v2", "руб."] + assert [sheet.cell(7, column).value for column in range(1, 5)] == [ + "Статья ВГО", + "=Компания А", + "Компания Б", + "Итого по группе", + ] + assert sheet["B7"].data_type == "s" + assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25] + assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5)) + assert sheet.freeze_panes == "B8" + assert audit.record.await_args.kwargs == { + "action": AuditAction.EXPORT, + "section": AuditSection.EXPORT, + "object_type": "consolidation_version", + "object_id": version_id, + "consolidation_version_id": version_id, + "after_value": {"report": "intercompany_result_table", "articles": 1}, + } + assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table/export", ("GET",)) in { + (route.path, tuple(sorted(route.methods))) for route in router.routes + } + + +async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None: + version_id = UUID("11111111-1111-1111-1111-111111111111") + info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2) + service = SimpleNamespace( + build_result_export=AsyncMock(return_value=(IntercompanyResultTableOut(companies=[], articles=[]), info)) + ) + audit = SimpleNamespace(record=AsyncMock()) + + def fail_workbook(_result: IntercompanyResultTableOut, _info: ResultTableExportInfo) -> bytes: + raise ValueError("xlsx failed") + + monkeypatch.setattr( + "src.api.endpoints.consolidations.intercompany_matrix.build_result_table_workbook", fail_workbook + ) + with pytest.raises(ValueError, match="xlsx failed"): + await export_intercompany_result_table(version_id, service, audit) + audit.record.assert_not_awaited() + + +def test_result_table_workbook_rejects_article_without_company_amount() -> None: + company = IntercompanyMatrixCompanyOut(id=UUID("11111111-1111-1111-1111-111111111111"), name="Компания А") + result = IntercompanyResultTableOut( + companies=[company], + articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")], + ) + with pytest.raises(KeyError): + build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index a5b1566..c9cc80c 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -80,6 +80,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно @@ -191,6 +192,17 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) assert not {"limit", "offset"} & names +def test_result_table_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None: + """Экспорт результирующей таблицы: 200 — xlsx, 422 — нет счетов статей, а не «неверный выбор счетов».""" + base = "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table" + export = schema["paths"][f"{base}/export"]["get"]["responses"] + xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + assert export["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"} + for responses in (export, schema["paths"][base]["get"]["responses"]): + assert responses["404"]["description"] == "Версия не найдена либо недоступна" + assert responses["422"]["description"] == "В плане счетов нет счетов статей результирующей таблицы" + + def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None: """T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422.""" checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"] diff --git a/openspec/changes/intercompany-result-table-export/design.md b/openspec/changes/intercompany-result-table-export/design.md new file mode 100644 index 0000000..7b02f29 --- /dev/null +++ b/openspec/changes/intercompany-result-table-export/design.md @@ -0,0 +1,32 @@ +## Context + +Реализация — PR RSHB/arm-core#159. Результирующая таблица ВГО (§6) — чистая функция от сводной ОСВ версии и периметра +(`calculate_result_table`); в БД не хранится (DECISION-5). Выгрузка повторяет устройство выгрузки шахматки +(`intercompany_matrix_export.py`, change `intercompany-matrix`). + +## Decisions + +1. JSON и xlsx строятся одним методом сервиса `_build_result_table` (`build_with_members` → `calculate_result_table`), + поэтому состав и порядок компаний, статьи и `group_total` совпадают; параметров у таблицы нет. +2. Книга: лист «Результирующая таблица», шапка (ГК, период, версия, единицы «руб.»), строка заголовка «Статья ВГО» / + компании / «Итого по группе», суммы — `Decimal` с форматом `#,##0.00`, закреплены заголовок и первая колонка. + Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx` (RFC 5987, запасное ASCII-имя). +3. Аудит `EXPORT` пишется после построения книги: ошибка расчёта (404, 422) или сборки книги события не оставляет. +4. Реквизиты версии в шапке и имени файла общие для шахматки и результирующей таблицы (`VersionExportInfo`, + `_write_details`, `_group_name`, `_xlsx_response`) — без копий кода. + +## Decisions made without discussion + +- 22.10.2026. Требование автора перенесено из `openspec/specs/consolidations-api` в дельту `ADDED` отдельным + требованием со сценариями (у автора — абзац внутри требования «Результирующая таблица ВГО» без сценария); событие + аудита добавлено в `audit-trail`. Обоснование: владелец поручил закрыть замечания ревью силами ревьюера, прецедент — + change `intercompany-matrix`. Цена ошибки: низкая — текст правится в этом change'е до архивации. +- 22.10.2026. Аудит экспорта результирующей таблицы оформлен отдельным `ADDED`-требованием, а не `MODIFIED` + «Аудит экспорта шахматки». Обоснование: у событий разный payload (`articles` вместо режима и счетов), отдельное + требование не переписывает согласованный текст. Цена ошибки: низкая. +- 22.10.2026. Компания без суммы в статье при сборке книги даёт `KeyError` вместо молчаливого 0. Обоснование: + `calculate_result_table` выдаёт сумму на каждую компанию периметра, пропуск — ошибка расчёта, которую нельзя прятать + в файле. Цена ошибки: при таком дефекте расчёта выгрузка ответит 500 вместо файла с нулём. +- 22.10.2026. Общие реквизиты книги вынесены в базовый `VersionExportInfo`, затронут и код выгрузки шахматки. + Обоснование: замечание ревью о копиях шапки и имени файла. Цена ошибки: конфликт слияния с PR #141 (тот меняет + `MatrixExportInfo`); порядок — сначала #141, затем ребейз #159. diff --git a/openspec/changes/intercompany-result-table-export/proposal.md b/openspec/changes/intercompany-result-table-export/proposal.md new file mode 100644 index 0000000..adec35f --- /dev/null +++ b/openspec/changes/intercompany-result-table-export/proposal.md @@ -0,0 +1,33 @@ +## Why + +Фронт шахматки (экран [31-01], результирующая таблица под матрицей) выгружает результирующую таблицу ВГО в xlsx — +запрос фронтенда к API. Реализация — PR RSHB/arm-core#159 (автор — Потапова). Требование автор дописала прямо в +`openspec/specs/`; при подготовке к слиянию оно оформлено дельтами этого change'а, как в change `intercompany-matrix` +(PR #114). + +## What Changes + +- `GET …/intercompany-matrix/result-table/export` — xlsx результирующей таблицы ВГО: те же статьи, компании и итоги, + что у `GET …/intercompany-matrix/result-table`, считается на лету из той же сводной ОСВ версии (§6). +- Каждая успешная выгрузка пишет `audit_event(EXPORT)` с `report = "intercompany_result_table"`. +- В OpenAPI у 422 маршрутов результирующей таблицы — своё описание («нет счетов статей»), а не «неверный выбор счетов». + +## Capabilities + +### New Capabilities +Нет. + +### Modified Capabilities +- `consolidations-api`: xlsx результирующей таблицы ВГО. +- `audit-trail`: аудит экспорта результирующей таблицы. + +## Impact + +Только `arm-core` (маршрут, сервис шахматки, построитель книги, тесты). Таблицы DBML v5.21 не меняются: действие +`EXPORT` и раздел `EXPORT` в `audit_action`/`audit_section` уже есть. Срез 5 (`09-delivery-plan.md`). + +## Non-goals + +- Строка «Итого» по статьям: её нет ни в JSON, ни в xlsx (§6 описывает только колонку «Итого по группе»). +- Пересчёт в тыс. руб. и знаки после запятой: книга в рублях, как выгрузка шахматки. +- Кнопка выгрузки во фронте (`web/`) и строка в `../docs/dev/frontend-api-guide.md` — отдельными изменениями. diff --git a/openspec/changes/intercompany-result-table-export/specs/audit-trail/spec.md b/openspec/changes/intercompany-result-table-export/specs/audit-trail/spec.md new file mode 100644 index 0000000..1c0ca79 --- /dev/null +++ b/openspec/changes/intercompany-result-table-export/specs/audit-trail/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Аудит экспорта результирующей таблицы ВГО +Каждый успешный `GET …/intercompany-matrix/result-table/export` SHALL добавлять отдельное неизменяемое событие +`audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`, +`object_id = consolidation_version_id`, `consolidation_version_id` версии, `user_id` инициатора, `before_value = null` +и `after_value = {report: "intercompany_result_table", articles}`, где `articles` — число статей в книге. Неуспешная +выгрузка (404, 422, ошибка сборки книги) события не пишет. + +#### Scenario: Выгрузка таблицы +- **WHEN** пользователь выгружает результирующую таблицу версии +- **THEN** в `audit_event` одна запись `EXPORT` с `after_value = {report: "intercompany_result_table", articles: 5}` + +#### Scenario: Нет счёта статьи +- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` +- **THEN** событие `EXPORT` не записано diff --git a/openspec/changes/intercompany-result-table-export/specs/consolidations-api/spec.md b/openspec/changes/intercompany-result-table-export/specs/consolidations-api/spec.md new file mode 100644 index 0000000..5ff2028 --- /dev/null +++ b/openspec/changes/intercompany-result-table-export/specs/consolidations-api/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: XLSX результирующей таблицы ВГО +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`, +policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`, +из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы +«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя +файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx`. Ошибки — как у JSON-маршрута: невидимая или +несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`. + +#### Scenario: Совпадение с JSON +- **WHEN** пользователь выгружает результирующую таблицу ГК «Русский бисквит» за 2024 Q1, версия 1 +- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, порядок компаний, названия статей, суммы по компаниям и + «Итого по группе» совпадают с ответом `GET …/result-table` той же версии + +#### Scenario: Нет счёта статьи +- **WHEN** в плане счетов нет кода 58.03 +- **THEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` с кодом в сообщении, файл не формируется + +#### Scenario: Версия вне видимости +- **WHEN** пользователь филиала выгружает таблицу версии чужой ГК +- **THEN** ответ 404 с тем же телом, что у несуществующей версии diff --git a/openspec/changes/intercompany-result-table-export/tasks.md b/openspec/changes/intercompany-result-table-export/tasks.md new file mode 100644 index 0000000..8309416 --- /dev/null +++ b/openspec/changes/intercompany-result-table-export/tasks.md @@ -0,0 +1,10 @@ +## 1. Реализация + +- [x] 1.1 Маршрут `GET …/intercompany-matrix/result-table/export`, построитель книги, аудит `EXPORT` (автор, PR #159) +- [x] 1.2 Замечания ревью: `EXPECTED_ROUTES`, формат ruff, общие реквизиты книги, `KeyError` вместо 0, описание 422, + тесты 422 без события `EXPORT` и OpenAPI-схемы экспорта + +## 2. После слияния + +- [ ] 2.1 Ребейз на `main` после слияния PR #141 (`build_with_members` возвращает объект), `make check-full` +- [ ] 2.2 Кнопка выгрузки на экране результирующей таблицы (`web/`) и строка в `../docs/dev/frontend-api-guide.md`