From ea51cb35c207e82a8d326d3ddd6e40e72ff572bb Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 21 Oct 2026 19:26:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20xlsx=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=92=D0=93=D0=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consolidations/intercompany_matrix.py | 34 +++++++- api/src/services/intercompany_matrix.py | 44 +++++++++- .../services/intercompany_matrix_export.py | 51 ++++++++++- api/tests/test_api_intercompany_matrix.py | 24 +++++- api/tests/test_intercompany_matrix_export.py | 84 ++++++++++++++++++- openspec/specs/consolidations-api/spec.md | 5 ++ 6 files changed, 234 insertions(+), 8 deletions(-) diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 2fd695a..82077a1 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") @@ -158,3 +158,35 @@ 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=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 = Response( + content=content, + media_type=XLSX_MEDIA_TYPE, + headers={ + "Content-Disposition": content_disposition(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..43e37bd 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -90,6 +90,25 @@ class MatrixExportInfo: return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" +@dataclass(frozen=True) +class ResultTableExportInfo: + group_name: str + year: int + quarter: PeriodQuarter + version_no: int + + @property + def filename(self) -> str: + return f"intercompany-result-table_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx" + + +@dataclass(frozen=True) +class ResultTableBuild: + table: IntercompanyResultTableOut + version: ConsolidationVersion + consolidation: Consolidation + + def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: if measure == "closing": value = row.closing_debit if side == "D" else row.closing_credit @@ -300,10 +319,31 @@ 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) + group_name = ( + await self._session.execute( + select(CompanyGroup.name).where(CompanyGroup.id == built.consolidation.company_group_id) + ) + ).scalar_one() + info = ResultTableExportInfo( + group_name, built.consolidation.year, built.consolidation.quarter, built.version.version_no + ) + return built.table, info + + 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, diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 081be3f..4895639 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 from src.services.osv_export import MONEY_FORMAT METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %") @@ -115,3 +115,50 @@ 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 = "Результирующая таблица" + _text(sheet, 1, 1, "Результирующая таблица ВГО") + for row, label, value in ( + (2, "ГК", info.group_name), + (3, "Период", f"{info.year}-{info.quarter.value}"), + (4, "Версия", f"v{info.version_no}"), + (5, "Единицы", "руб."), + ): + _text(sheet, row, 1, label) + _text(sheet, row, 2, value) + sheet.cell(row, 1).font = Font(bold=True) + + 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.get(company.id, 0)).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..13687ef 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 @@ -231,6 +231,28 @@ async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: A assert "58.03" in response.json()["detail"]["message"] +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"]) async def test_export_filename_header_and_percentage( auth_client: AuthClientFactory, seeded: AsyncSession, mode: str diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 20fcc97..c249525 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,17 @@ 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, ) -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 +238,74 @@ 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() diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 5b64bd6..0bcd8c8 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -244,6 +244,11 @@ SHALL возвращать все правила версии массивом. из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что JSON-таблица. +Имя файла: `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx`. +Успешная выгрузка SHALL добавлять событие `EXPORT` с `report = "intercompany_result_table"`. + #### Scenario: Компания без ОСВ - **WHEN** в периметре есть компания без подходящих строк ОСВ - **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`