From 0f107f8cf8bad376937e1dcc0b3116ef6c405bea Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 3 Nov 2026 15:15:17 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=20=D0=B2=20result=20table=20(=D1=88=D0=B0=D1=85=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/schemas/consolidations.py | 7 +++++++ api/src/services/intercompany_matrix.py | 14 +++++++++++++- api/src/services/intercompany_matrix_export.py | 12 ++++++++++++ api/tests/test_api_intercompany_matrix.py | 8 ++++++++ api/tests/test_intercompany_matrix.py | 5 +++++ api/tests/test_intercompany_matrix_export.py | 15 ++++++++++++++- openspec/specs/consolidations-api/spec.md | 10 +++++++--- 7 files changed, 66 insertions(+), 5 deletions(-) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 7702cfc..58f9dc3 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -699,3 +699,10 @@ class IntercompanyResultTableOut(BaseModel): companies: list[IntercompanyMatrixCompanyOut] articles: list[IntercompanyResultArticleOut] + company_totals: list[IntercompanyResultAmountOut] + grand_total: Decimal + + @field_validator("grand_total") + @classmethod + def _two_decimals(cls, value: Decimal) -> Decimal: + return value.quantize(CENT, rounding=ROUND_HALF_UP) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index cd3d7e3..13fa14c 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -367,7 +367,19 @@ def calculate_result_table( group_total=sum((item.amount for item in company_amounts), ZERO), ) ) - return IntercompanyResultTableOut(companies=list(companies), articles=articles) + company_totals = [ + IntercompanyResultAmountOut( + company_id=company.id, + amount=sum((article.amounts[index].amount for article in articles), ZERO), + ) + for index, company in enumerate(companies) + ] + return IntercompanyResultTableOut( + companies=list(companies), + articles=articles, + company_totals=company_totals, + grand_total=sum((article.group_total for article in articles), ZERO), + ) def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut: diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index a0e0fd2..1d0d0a7 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -180,6 +180,18 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result total.number_format = MONEY_FORMAT total.font = Font(bold=True) + summary_row = header_row + len(result.articles) + 1 + _text(sheet, summary_row, 1, "Итого") + sheet.cell(summary_row, 1).font = Font(bold=True) + totals = {item.company_id: item.amount for item in result.company_totals} + for column, company in enumerate(result.companies, start=2): + cell = sheet.cell(summary_row, column, totals[company.id]) + cell.number_format = MONEY_FORMAT + cell.font = Font(bold=True) + grand_total = sheet.cell(summary_row, total_column, result.grand_total) + grand_total.number_format = MONEY_FORMAT + grand_total.font = Font(bold=True) + sheet.freeze_panes = "B8" buffer = BytesIO() workbook.save(buffer) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index d20672a..ed3d07b 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -694,6 +694,8 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"] assert receivables["group_total"] == "100.25" assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables) + assert sorted(item["amount"] for item in data["company_totals"]) == ["0.00", "100.25"] + assert data["grand_total"] == "100.25" 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"] @@ -704,6 +706,12 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, 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"]) + summary_row = 8 + len(data["articles"]) + assert sheet.cell(summary_row, 1).value == "Итого" + totals = {item["company_id"]: Decimal(item["amount"]) for item in data["company_totals"]} + for col, company in enumerate(data["companies"], start=2): + assert Decimal(str(sheet.cell(summary_row, col).value)) == totals[company["id"]] + assert Decimal(str(sheet.cell(summary_row, len(data["companies"]) + 2).value)) == Decimal(data["grand_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"])} diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 2719670..08dea4c 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -414,6 +414,11 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: assert isinstance(result, IntercompanyResultTableOut) response = result assert response.articles[0].amounts[0].company_id == company_a + assert [(item.company_id, item.amount) for item in result.company_totals] == [ + (company_a, Decimal("140.00")), + (company_b, Decimal("110.00")), + ] + assert result.grand_total == Decimal("250.00") def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index e4a72d4..8488ae2 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -339,6 +339,11 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: group_total="120.25", ) ], + company_totals=[ + IntercompanyResultAmountOut(company_id=version_id, amount="100.25"), + IntercompanyResultAmountOut(company_id=other_id, amount="20.00"), + ], + grand_total="120.25", ) info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2) service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info))) @@ -359,6 +364,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: 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.cell(9, column).value for column in range(1, 5)] == ["Итого", 100.25, 20, 120.25] assert sheet.freeze_panes == "B8" assert audit.record.await_args.kwargs == { "action": AuditAction.EXPORT, @@ -377,7 +383,12 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte 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)) + build_result_export=AsyncMock( + return_value=( + IntercompanyResultTableOut(companies=[], articles=[], company_totals=[], grand_total="0"), + info, + ) + ) ) audit = SimpleNamespace(record=AsyncMock()) @@ -397,6 +408,8 @@ def test_result_table_workbook_rejects_article_without_company_amount() -> None: result = IntercompanyResultTableOut( companies=[company], articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")], + company_totals=[IntercompanyResultAmountOut(company_id=company.id, amount="0")], + grand_total="0", ) with pytest.raises(KeyError): build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)) diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 15004ba..ccece5c 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -253,18 +253,21 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: Результирующая таблица ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` -(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], company_totals[], grand_total}`. Компании — все члены периметра по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`. Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. -`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` +`company_totals[] {company_id, amount}` SHALL содержать сумму всех статей для каждого ЮЛ в порядке `companies[]`; +`grand_total` — сумму `group_total` всех статей. Итоги считаются из округлённых значений ответа. +`amount`, `group_total` и `grand_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` (например, `"0.00"`). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. #### Scenario: Компания без ОСВ - **WHEN** в периметре есть компания без подходящих строк ОСВ - **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"` +- **AND** её итог в `company_totals` — строка `"0.00"` #### Scenario: Нет обязательного счёта - **WHEN** в плане счетов отсутствует счёт 58.03 @@ -273,7 +276,8 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: XLSX результирующей таблицы ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`, -из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы +из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит `company_totals` и `grand_total`. +Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы «руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx`. Ошибки — как у JSON-маршрута: невидимая или несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`. -- 2.47.2 From 994d6be94c4be515d911da3897cf4b4cd01c5ec8 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 3 Nov 2026 17:45:54 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20all,=20manual?= =?UTF-8?q?=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BD=D0=B8=D1=85=20=D1=81?= =?UTF-8?q?=D1=83=D0=BC=D0=BC=D1=8B=20=D0=BF=D0=BE=20=D0=B4=D0=B5=D0=B1?= =?UTF-8?q?=D0=B5=D1=82=D1=83,=20=D0=BA=D1=80=D0=B5=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D0=BF=D0=BE=20=D1=8E=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- .../consolidations/intercompany_matrix.py | 71 +++--------- api/src/schemas/consolidations.py | 11 +- api/src/services/intercompany_matrix.py | 70 +++++++----- .../services/intercompany_matrix_export.py | 34 +++++- api/tests/test_api_intercompany_matrix.py | 73 +++++++++--- api/tests/test_intercompany_matrix.py | 104 ++++++++++++++++-- api/tests/test_intercompany_matrix_export.py | 101 ++++++++++++++--- api/tests/test_openapi_routes.py | 6 +- openspec/specs/audit-trail/spec.md | 5 +- openspec/specs/consolidated-osv/spec.md | 33 ++++-- 11 files changed, 364 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index e25604d..6b03ba0 100644 --- a/README.md +++ b/README.md @@ -934,11 +934,11 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` ## Шахматка и результирующая таблица ВГО (Срез 5) API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица -`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица +`POST …/intercompany-matrix` с необязательными списками счетов Дт/Кт и ID ЮЛ и его XLSX `POST …/intercompany-matrix/export`, статичная таблица `GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` (аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации): `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. -Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма +Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые. `one_sided` — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии `consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату diff --git a/api/src/api/endpoints/consolidations/intercompany_matrix.py b/api/src/api/endpoints/consolidations/intercompany_matrix.py index 5f41642..536e18b 100644 --- a/api/src/api/endpoints/consolidations/intercompany_matrix.py +++ b/api/src/api/endpoints/consolidations/intercompany_matrix.py @@ -13,8 +13,8 @@ from src.common.filenames import content_disposition from src.models.enums import AuditAction, AuditSection from src.schemas.common import ErrorDetail from src.schemas.consolidations import ( - IntercompanyMatrixManualIn, IntercompanyMatrixOut, + IntercompanyMatrixSelectionIn, IntercompanyResultTableOut, ) from src.services.intercompany_matrix import MatrixExportInfo @@ -56,7 +56,7 @@ async def _export_response( info: MatrixExportInfo, audit: RequestAudit, *, - selection: IntercompanyMatrixManualIn | None = None, + selection: IntercompanyMatrixSelectionIn | 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): @@ -73,6 +73,7 @@ async def _export_response( "mode": info.mode, "debit_account_ids": [str(account_id) for account_id in selection.debit_account_ids] if selection else [], "credit_account_ids": [str(account_id) for account_id in selection.credit_account_ids] if selection else [], + "legal_entity_ids": [str(entity_id) for entity_id in selection.legal_entity_ids] if selection else [], **( { "rule_id": str(info.rule_id), @@ -88,84 +89,48 @@ async def _export_response( return response -@router.get( - "/{version_id}/intercompany-matrix/all", - response_model=IntercompanyMatrixOut, - dependencies=VIEW, - responses=ERRORS, - summary="Рассчитать шахматку по всем счетам", -) -async def build_intercompany_matrix_all( - version_id: uuid.UUID, - service: IntercompanyMatrixServiceDep, -) -> IntercompanyMatrixOut: - """Все счета с подходящим видом дебета или кредита.""" - return await service.build_accounts(version_id, mode="ALL") - - -@router.get( - "/{version_id}/intercompany-matrix/all/export", - response_class=Response, - dependencies=VIEW, - responses=EXPORT_RESPONSES, - summary="Скачать шахматку по всем счетам в XLSX", -) -async def export_intercompany_matrix_all( - version_id: uuid.UUID, - service: IntercompanyMatrixServiceDep, - audit: Annotated[RequestAudit, Depends(get_request_audit)], -) -> Response: - matrix, info = await service.build_export(version_id, mode="ALL") - return await _export_response(version_id, matrix, info, audit) - - @router.post( - "/{version_id}/intercompany-matrix/manual", + "/{version_id}/intercompany-matrix", response_model=IntercompanyMatrixOut, dependencies=VIEW, responses=ERRORS, - summary="Рассчитать шахматку по выбранным счетам", + summary="Рассчитать шахматку по выбранным или всем счетам", ) -async def build_intercompany_matrix_manual( +async def build_intercompany_matrix( version_id: uuid.UUID, - selection: IntercompanyMatrixManualIn, service: IntercompanyMatrixServiceDep, + selection: IntercompanyMatrixSelectionIn | None = None, ) -> IntercompanyMatrixOut: - """Выбранные счета раскрываются по иерархии.""" + selection = selection or IntercompanyMatrixSelectionIn() return await service.build_accounts( version_id, - mode="MANUAL", debit_account_ids=selection.debit_account_ids, credit_account_ids=selection.credit_account_ids, + legal_entity_ids=selection.legal_entity_ids, ) @router.post( - "/{version_id}/intercompany-matrix/manual/export", + "/{version_id}/intercompany-matrix/export", response_class=Response, dependencies=VIEW, responses=EXPORT_RESPONSES, - summary="Скачать шахматку по выбранным счетам в XLSX", + summary="Скачать шахматку по выбранным или всем счетам в XLSX", ) -async def export_intercompany_matrix_manual( +async def export_intercompany_matrix( version_id: uuid.UUID, - selection: IntercompanyMatrixManualIn, service: IntercompanyMatrixServiceDep, audit: Annotated[RequestAudit, Depends(get_request_audit)], + selection: IntercompanyMatrixSelectionIn | None = None, ) -> Response: + selection = selection or IntercompanyMatrixSelectionIn() matrix, info = await service.build_export( version_id, - mode="MANUAL", debit_account_ids=selection.debit_account_ids, credit_account_ids=selection.credit_account_ids, + legal_entity_ids=selection.legal_entity_ids, ) - return await _export_response( - version_id, - matrix, - info, - audit, - selection=selection, - ) + return await _export_response(version_id, matrix, info, audit, selection=selection) @router.get( @@ -181,7 +146,7 @@ async def build_intercompany_matrix_preset( service: IntercompanyMatrixServiceDep, ) -> IntercompanyMatrixOut: """Шахматка §6 по счетам сторон правила §4.""" - return await service.build_accounts(version_id, mode="PRESET", rule_id=rule_id) + return await service.build_accounts(version_id, rule_id=rule_id) @router.get( @@ -198,7 +163,7 @@ async def export_intercompany_matrix_preset( audit: Annotated[RequestAudit, Depends(get_request_audit)], ) -> Response: """XLSX шахматки §6 по правилу §4 с аудитом выгрузки.""" - matrix, info = await service.build_export(version_id, mode="PRESET", rule_id=rule_id) + matrix, info = await service.build_export(version_id, rule_id=rule_id) return await _export_response(version_id, matrix, info, audit) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 58f9dc3..a4472e3 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -622,11 +622,12 @@ class EliminationTotalsOut(BaseModel): items: list[EliminationTotalItemOut] -class IntercompanyMatrixManualIn(BaseModel): - """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" +class IntercompanyMatrixSelectionIn(BaseModel): + """Пустые списки счетов и ЮЛ означают все подходящие счета и весь периметр.""" - debit_account_ids: list[uuid.UUID] = Field(min_length=1) - credit_account_ids: list[uuid.UUID] = Field(min_length=1) + debit_account_ids: list[uuid.UUID] = Field(default_factory=list) + credit_account_ids: list[uuid.UUID] = Field(default_factory=list) + legal_entity_ids: list[uuid.UUID] = Field(default_factory=list) class IntercompanyMatrixCellOut(BaseModel): @@ -666,6 +667,8 @@ class IntercompanyMatrixOut(BaseModel): companies: list[IntercompanyMatrixCompanyOut] cells: list[IntercompanyMatrixCellOut] + total_debet: dict[uuid.UUID, Decimal] = Field(default_factory=dict) + total_credit: dict[uuid.UUID, Decimal] = Field(default_factory=dict) class IntercompanyResultAmountOut(BaseModel): diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 13fa14c..4b34224 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -265,7 +265,14 @@ def calculate_matrix( osv_entry_ids_credit=credit_ids, ) ) - return IntercompanyMatrixOut(companies=list(company_info), cells=cells) + total_debet = {company_id: ZERO for company_id in company_ids} + total_credit = {company_id: ZERO for company_id in company_ids} + for cell in cells: + total_debet[cell.company_a_id] += cell.amount_a + total_credit[cell.company_b_id] += cell.amount_b + return IntercompanyMatrixOut( + companies=list(company_info), cells=cells, total_debet=total_debet, total_credit=total_credit + ) def _make_account_tree( @@ -677,21 +684,17 @@ class IntercompanyMatrixService: self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), + legal_entity_ids: Sequence[uuid.UUID] = (), rule_id: uuid.UUID | None = None, ) -> IntercompanyMatrixOut: - """Построить шахматку по выбранному режиму. - - ALL включает балансовые счета соответствующей стороны (без забалансовых); MANUAL — выбранные счета и их - потомков; PRESET берёт счета и показатель из правила с идентификатором `rule_id`. - """ + """Построить шахматку: пустая сторона берёт все балансовые счета; правило задаёт пресет.""" result = await self._build_accounts( version_id, - mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids, + legal_entity_ids=legal_entity_ids, rule_id=rule_id, ) return result.matrix @@ -700,21 +703,24 @@ class IntercompanyMatrixService: self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), + legal_entity_ids: Sequence[uuid.UUID] = (), rule_id: uuid.UUID | None = None, ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: """Матрица и реквизиты книги из одного расчёта свода.""" result = await self._build_accounts( version_id, - mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids, + legal_entity_ids=legal_entity_ids, rule_id=rule_id, ) group_name = await self._group_name(result.consolidation) by_id = {account.id: account.code for account in result.accounts} + mode: Literal["ALL", "MANUAL", "PRESET"] = ( + "PRESET" if rule_id is not None else "MANUAL" if debit_account_ids or credit_account_ids else "ALL" + ) if mode == "PRESET": debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids)) credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids)) @@ -782,37 +788,43 @@ class IntercompanyMatrixService: self, version_id: uuid.UUID, *, - mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID], credit_account_ids: Sequence[uuid.UUID], + legal_entity_ids: Sequence[uuid.UUID], rule_id: uuid.UUID | None = None, ) -> MatrixBuildResult: osv_result = await self._osv_service.build_with_members(version_id) rows = osv_result.consolidated.rows + members = osv_result.members + if legal_entity_ids: + selected_ids = set(legal_entity_ids) + if selected_ids - members.keys(): + raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "Выбранное ЮЛ не входит в периметр версии") + members = {entity_id: entity for entity_id, entity in members.items() if entity_id in selected_ids} accounts = await directory_repo.list_accounts(self._session) by_id, children = _make_account_tree(accounts) rule: EliminationRule | None = None - if mode == "ALL": + if rule_id is not None: if debit_account_ids or credit_account_ids: - raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") - debit = frozenset( - account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS - ) - credit = frozenset( - account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS - ) - selection = MatrixSelection(debit, credit, "closing") - elif mode == "MANUAL": - if not debit_account_ids or not credit_account_ids: - raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") - debit = _select_accounts(debit_account_ids, (), "D", by_id, children) - credit = _select_accounts(credit_account_ids, (), "K", by_id, children) - selection = MatrixSelection(debit, credit, "closing") - elif mode == "PRESET": + raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для пресета не нужно указывать счета") selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: - raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") - matrix = self._calculate(rows, osv_result.members, selection) + debit = ( + _select_accounts(debit_account_ids, (), "D", by_id, children) + if debit_account_ids + else frozenset( + account.id for account in accounts if not account.is_off_balance and account.kind in DEBIT_KINDS + ) + ) + credit = ( + _select_accounts(credit_account_ids, (), "K", by_id, children) + if credit_account_ids + else frozenset( + account.id for account in accounts if not account.is_off_balance and account.kind in CREDIT_KINDS + ) + ) + selection = MatrixSelection(debit, credit, "closing") + matrix = self._calculate(rows, members, selection) return MatrixBuildResult( matrix=matrix, version=osv_result.version, diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 1d0d0a7..7a4ca7c 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -56,8 +56,8 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> int: mode_name = {"ALL": all_accounts, "MANUAL": "Выбранные счета", "PRESET": "Пресет"}[info.mode] extra = [ ("Режим", mode_name), - ("Счета дебета", ", ".join(info.debit_account_codes) if info.mode != "ALL" else all_accounts), - ("Счета кредита", ", ".join(info.credit_account_codes) if info.mode != "ALL" else all_accounts), + ("Счета дебета", ", ".join(info.debit_account_codes) or all_accounts), + ("Счета кредита", ", ".join(info.credit_account_codes) or all_accounts), ("Показатель", "Сальдо" if info.measure == "closing" else "Оборот"), ] if info.mode == "PRESET": @@ -88,6 +88,31 @@ def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMat _skipped_comment(sheet, row, column, cell.skipped_osv_entry_count) +def _write_matrix_totals( + sheet: Worksheet, matrix: IntercompanyMatrixOut, data_start_row: int, total_column: int +) -> None: + for company_index, company in enumerate(matrix.companies): + start_row = data_start_row + company_index * len(METRICS) + sheet.merge_cells( + start_row=start_row, + start_column=total_column, + end_row=start_row + len(METRICS) - 1, + end_column=total_column, + ) + debit_total = sheet.cell(start_row, total_column, matrix.total_debet[company.id]) + debit_total.number_format = MONEY_FORMAT + debit_total.font = Font(bold=True) + debit_total.alignment = Alignment(horizontal="right", vertical="center") + + credit_total_row = data_start_row + len(matrix.companies) * len(METRICS) + _text(sheet, credit_total_row, 1, "Кредит") + sheet.cell(credit_total_row, 1).font = Font(bold=True) + for column, company in enumerate(matrix.companies, start=3): + credit_total = sheet.cell(credit_total_row, column, matrix.total_credit[company.id]) + credit_total.number_format = MONEY_FORMAT + credit_total.font = Font(bold=True) + + def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> bytes: workbook = Workbook() sheet = workbook.active @@ -99,10 +124,13 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) _text(sheet, table_header_row, 2, "Показатель") sheet.column_dimensions["A"].width = 32 sheet.column_dimensions["B"].width = 40 + debit_total_column = len(matrix.companies) + 3 for column, company in enumerate(matrix.companies, start=3): _text(sheet, table_header_row, column, company.name) sheet.column_dimensions[get_column_letter(column)].width = 26 + _text(sheet, table_header_row, debit_total_column, "Дебет") + sheet.column_dimensions[get_column_letter(debit_total_column)].width = 26 sheet.row_dimensions[table_header_row].height = 36 for header in sheet[table_header_row]: header.font = Font(bold=True) @@ -142,6 +170,8 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) marked.fill = THRESHOLD_FILL marked.font = THRESHOLD_FONT + _write_matrix_totals(sheet, matrix, data_start_row, debit_total_column) + sheet.freeze_panes = f"C{data_start_row}" buffer = BytesIO() workbook.save(buffer) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index ed3d07b..1490c34 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -28,7 +28,7 @@ pytestmark = pytest.mark.integration OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422 KA_RF = "ka_rf@armka.local" RECEIVABLES_PAYABLES_RULE_NO = 6 -ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export") +ROUTES = ("matrix", "result-table", "matrix/export", "result-table/export") @dataclass @@ -60,8 +60,11 @@ async def _world(session: AsyncSession, *, members: int = 2) -> World: async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, selection: dict) -> Response: - url = f"/consolidations/versions/{version_id}/intercompany-matrix/{suffix}" - return await client.post(url, json=selection) if suffix.startswith("manual") else await client.get(url) + base = f"/consolidations/versions/{version_id}/intercompany-matrix" + if suffix.startswith("matrix"): + url = base + ("/export" if suffix.endswith("/export") else "") + return await client.post(url, json=selection) + return await client.get(f"{base}/{suffix}") async def _source( @@ -91,12 +94,12 @@ async def _source( return document.id -@pytest.mark.parametrize("suffix", ["all", "manual"]) -async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None: +@pytest.mark.parametrize("selection", [{}, {"debit_account_ids": []}]) +async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSession, selection: dict) -> None: world = await _world(seeded, members=0) - response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, "matrix", selection) assert response.status_code == OK, response.text - assert response.json() == {"companies": [], "cells": []} + assert response.json() == {"companies": [], "cells": [], "total_debet": {}, "total_credit": {}} @pytest.mark.parametrize("suffix", ROUTES) @@ -126,7 +129,7 @@ async def test_matrix_reuses_members_and_reads_accounts_through_repository( async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) await _source(seeded, world, credit=False, amount="999", account="58.03") - response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection) assert response.status_code == OK, response.text assert len(response.json()["cells"]) == len(world.members) for cell in response.json()["cells"]: @@ -143,6 +146,37 @@ async def test_no_osv_for_selected_accounts(auth_client: AuthClientFactory, seed assert cell["skipped_osv_entry_count"] == 0 +async def test_company_filter_applies_to_matrix_and_export( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + world = await _world(seeded, members=3) + await _source(seeded, world, credit=False, amount="100") + selected = [str(member.id) for member in world.members[:2]] + selection = {**world.selection, "legal_entity_ids": selected} + client = await auth_client(KA_RF) + + response = await _request(client, world.version.id, "matrix", selection) + export = await _request(client, world.version.id, "matrix/export", selection) + + assert response.status_code == export.status_code == OK + data = response.json() + assert [company["id"] for company in data["companies"]] == selected + assert len(data["cells"]) == 2 # noqa: PLR2004 - одна направленная ячейка на каждую сторону пары + assert set(data["total_debet"]) == set(data["total_credit"]) == set(selected) + sheet = load_workbook(BytesIO(export.content))["Шахматка"] + assert [sheet.cell(10, column).value for column in range(3, 6)] == [ + world.members[0].name, + world.members[1].name, + "Дебет", + ] + audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() + assert audit.after_value["legal_entity_ids"] == selected + + invalid = await _request(client, world.version.id, "matrix", {"legal_entity_ids": [str(uuid.uuid4())]}) + assert invalid.status_code == UNPROCESSABLE + assert invalid.json()["detail"]["code"] == "ENTITY_NOT_IN_PERIMETER" + + async def test_preset_uses_rule_sides_and_exclusions(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) rule = ( @@ -573,7 +607,7 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( await _source(seeded, world, credit=False, amount="100", account="001") client = await auth_client(KA_RF) - all_response = await _request(client, world.version.id, "all", world.selection) + all_response = await _request(client, world.version.id, "matrix", {}) assert all_response.status_code == OK, all_response.text assert all(Decimal(cell["amount_a"]) == 0 for cell in all_response.json()["cells"]) @@ -581,10 +615,12 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( "debit_account_ids": [str(world.accounts["001"].id)], "credit_account_ids": [str(world.accounts["60.01"].id)], } - manual_response = await _request(client, world.version.id, "manual", manual_selection) + manual_response = await _request(client, world.version.id, "matrix", manual_selection) assert manual_response.status_code == OK, manual_response.text cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == Decimal("100") + assert manual_response.json()["total_debet"][str(world.members[0].id)] == "100.00" + assert all(amount == "0.00" for amount in manual_response.json()["total_credit"].values()) async def test_unmapped_account_source_is_visible_in_cell_count( @@ -596,7 +632,7 @@ async def test_unmapped_account_source_is_visible_in_cell_count( entry.account_id = None await seeded.flush() await _source(seeded, world, credit=True, amount="80") - response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection) assert response.status_code == OK, response.text cells = response.json()["cells"] assert [cell["skipped_osv_entry_count"] for cell in cells] == [1, 1] @@ -614,7 +650,7 @@ async def test_pair_without_counterpart( ) -> None: world = await _world(seeded) await _source(seeded, world, credit=credit, amount=amount) - response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection) assert response.status_code == OK, response.text cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == (Decimal(0) if credit else Decimal(amount)) @@ -646,7 +682,7 @@ async def test_rounded_percent_in_api( world = await _world(seeded) await _source(seeded, world, credit=False, amount=debit) await _source(seeded, world, credit=True, amount=credit) - response = await _request(await auth_client(KA_RF), world.version.id, "manual", world.selection) + response = await _request(await auth_client(KA_RF), world.version.id, "matrix", world.selection) assert response.status_code == OK, response.text cell = next(c for c in response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert cell["variance_percent"] == percent @@ -654,7 +690,7 @@ async def test_rounded_percent_in_api( assert cell["one_sided"] is False -@pytest.mark.parametrize("suffix", ["manual", "manual/export"]) +@pytest.mark.parametrize("suffix", ["matrix", "matrix/export"]) @pytest.mark.parametrize("side", ["debit_account_ids", "credit_account_ids"]) @pytest.mark.parametrize("code", ["ACCOUNT_NOT_FOUND", "ACCOUNT_KIND_MISMATCH"]) async def test_invalid_account_selection( @@ -726,7 +762,9 @@ async def test_export_filename_header_and_percentage( group_name = ( await seeded.scalars(select(CompanyGroup.name).where(CompanyGroup.id == world.members[0].company_group_id)) ).one() - response = await _request(await auth_client(KA_RF), world.version.id, f"{mode}/export", world.selection) + response = await _request( + await auth_client(KA_RF), world.version.id, "matrix/export", world.selection if mode == "manual" else {} + ) assert response.status_code == OK, response.text disposition = response.headers["content-disposition"] assert unquote(disposition.split("filename*=UTF-8''", 1)[1]) == ( @@ -755,7 +793,9 @@ async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: Asyn client = await auth_client(KA_RF) query = select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT) for _ in range(2): - response = await _request(client, world.version.id, f"{mode}/export", world.selection) + response = await _request( + client, world.version.id, "matrix/export", world.selection if mode == "manual" else {} + ) assert response.status_code == OK, response.text events = (await seeded.scalars(query)).all() assert len(events) == 2 # noqa: PLR2004 - отдельное событие на каждую выгрузку @@ -769,5 +809,6 @@ async def test_export_appends_audit(auth_client: AuthClientFactory, seeded: Asyn "report": "intercompany_matrix", "mode": mode.upper(), "cells": len(world.members), + "legal_entity_ids": [], **(world.selection if mode == "manual" else {"debit_account_ids": [], "credit_account_ids": []}), } diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 08dea4c..618492d 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -9,12 +9,13 @@ from unittest.mock import AsyncMock, Mock import pytest from fastapi import status as http_status -from src.api.endpoints.consolidations.intercompany_matrix import router -from src.models.enums import AccountKind, StatementType +from src.api.endpoints.consolidations.intercompany_matrix import build_intercompany_matrix, router +from src.models.enums import AccountKind, PeriodQuarter, StatementType from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, IntercompanyMatrixOut, + IntercompanyMatrixSelectionIn, IntercompanyResultTableOut, ) from src.services import intercompany_matrix as matrix @@ -44,10 +45,11 @@ def _row( ) -def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: +def test_routes_and_matrix_expands_manual_accounts() -> None: paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} - assert ("/consolidations/versions/{version_id}/intercompany-matrix/all", ("GET",)) in paths - assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/export", ("POST",)) in paths + assert not any(path.endswith(("/all", "/manual", "/all/export", "/manual/export")) for path, _ in paths) assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}", ("GET",)) in paths assert not any(path.endswith("/unpaired-osv") for path, _ in paths) assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table", ("GET",)) in paths @@ -89,6 +91,79 @@ def test_routes_are_separate_and_matrix_expands_manual_accounts() -> None: ] +def test_matrix_totals_accumulate_each_company_and_side() -> None: + company_a, company_b, company_c = (uuid.uuid4() for _ in range(3)) + debit_account, credit_account = uuid.uuid4(), uuid.uuid4() + companies = [ + IntercompanyMatrixCompanyOut(id=company_id, name=str(index)) + for index, company_id in enumerate((company_a, company_b, company_c)) + ] + result = matrix.calculate_matrix( + [ + _row(company_a, company_b, debit_account, debit="100"), + _row(company_a, company_c, debit_account, debit="20"), + _row(company_c, company_a, debit_account, debit="5"), + _row(company_b, company_a, credit_account, credit="70"), + _row(company_c, company_a, credit_account, credit="10"), + _row(company_a, company_b, credit_account, credit="3"), + ], + companies, + matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"), + ) + + assert result.total_debet == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")} + assert result.total_credit == {company_a: Decimal("3"), company_b: Decimal("70"), company_c: Decimal("10")} + assert result.model_dump(mode="json")["total_debet"][str(company_a)] == "120.00" + + +@pytest.mark.parametrize( + "payload", + [ + None, + {}, + {"debit_account_ids": []}, + {"credit_account_ids": [uuid.uuid4()]}, + {"legal_entity_ids": [uuid.uuid4()]}, + ], +) +async def test_unified_matrix_route_passes_selection(payload: dict | None) -> None: + version_id = uuid.uuid4() + selection = IntercompanyMatrixSelectionIn(**payload) if payload is not None else None + result = IntercompanyMatrixOut(companies=[], cells=[]) + service = SimpleNamespace(build_accounts=AsyncMock(return_value=result)) + + assert await build_intercompany_matrix(version_id, service, selection) is result + expected_selection = selection or IntercompanyMatrixSelectionIn() + service.build_accounts.assert_awaited_once_with( + version_id, + debit_account_ids=expected_selection.debit_account_ids, + credit_account_ids=expected_selection.credit_account_ids, + legal_entity_ids=expected_selection.legal_entity_ids, + ) + + +async def test_export_mode_is_derived_from_account_lists(monkeypatch: pytest.MonkeyPatch) -> None: + debit_id = uuid.uuid4() + built = SimpleNamespace( + matrix=IntercompanyMatrixOut(companies=[], cells=[]), + consolidation=SimpleNamespace(year=2024, quarter=PeriodQuarter.Q1), + version=SimpleNamespace(version_no=1), + accounts=[SimpleNamespace(id=debit_id, code="62.01")], + selection=matrix.MatrixSelection(frozenset(), frozenset(), "closing"), + rule=None, + ) + service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, SimpleNamespace()) + monkeypatch.setattr(service, "_build_accounts", AsyncMock(return_value=built)) + monkeypatch.setattr(service, "_group_name", AsyncMock(return_value="ГК")) + + _, all_info = await service.build_export(uuid.uuid4()) + _, manual_info = await service.build_export(uuid.uuid4(), debit_account_ids=[debit_id]) + + assert (all_info.mode, manual_info.mode) == ("ALL", "MANUAL") + assert manual_info.debit_account_codes == ("62.01",) + assert manual_info.credit_account_codes == () + + 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()] @@ -158,14 +233,14 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey captured = [] monkeypatch.setattr(service, "_calculate", lambda rows, members, selection: captured.append(selection)) - await service.build_accounts(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) + await service.build_accounts(uuid.uuid4(), rule_id=uuid.uuid4()) assert len(captured) == 1 assert captured[0].company_1_account_ids == {parent.id} assert captured[0].company_2_account_ids == {parent.id, child.id} assert captured[0].measure == "turnover" - _, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4()) + _, info = await service.build_export(uuid.uuid4(), rule_id=uuid.uuid4()) assert info.mode == "PRESET" assert info.debit_account_codes == ("76",) assert info.credit_account_codes == ("76", "76.01") @@ -253,14 +328,25 @@ async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.Monkey ) service = matrix.IntercompanyMatrixService(SimpleNamespace(), None, osv_service) - all_matrix = await service.build_accounts(uuid.uuid4(), mode="ALL") + all_matrix = await service.build_accounts(uuid.uuid4()) manual_matrix = await service.build_accounts( - uuid.uuid4(), mode="MANUAL", debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id] + uuid.uuid4(), debit_account_ids=[off_balance.id], credit_account_ids=[off_balance_credit.id] ) + debit_only = await service.build_accounts(uuid.uuid4(), debit_account_ids=[off_balance.id]) + credit_only = await service.build_accounts(uuid.uuid4(), credit_account_ids=[off_balance_credit.id]) + filtered = await service.build_accounts(uuid.uuid4(), legal_entity_ids=[company_a]) assert all_matrix.cells[0].amount_a == Decimal("30") assert all_matrix.cells[0].amount_b == Decimal("20") assert manual_matrix.cells[0].amount_a == Decimal("100") assert manual_matrix.cells[0].amount_b == Decimal("40") + assert (debit_only.cells[0].amount_a, debit_only.cells[0].amount_b) == (Decimal("100"), Decimal("20")) + assert (credit_only.cells[0].amount_a, credit_only.cells[0].amount_b) == (Decimal("30"), Decimal("40")) + assert [company.id for company in filtered.companies] == [company_a] + assert filtered.cells == [] + assert filtered.total_debet == filtered.total_credit == {company_a: Decimal("0")} + with pytest.raises(matrix.DomainValidationError) as error: + await service.build_accounts(uuid.uuid4(), legal_entity_ids=[uuid.uuid4()]) + assert error.value.code == "ENTITY_NOT_IN_PERIMETER" @pytest.mark.parametrize( diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 8488ae2..d3b50ab 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -1,5 +1,7 @@ """XLSX-выгрузка шахматки из готового результата расчёта.""" +from dataclasses import replace +from decimal import Decimal from io import BytesIO from types import SimpleNamespace from unittest.mock import AsyncMock @@ -7,11 +9,11 @@ from urllib.parse import unquote from uuid import UUID import pytest +from fastapi import status as http_status from openpyxl import load_workbook from src.api.endpoints.consolidations.intercompany_matrix import ( - export_intercompany_matrix_all, - export_intercompany_matrix_manual, + export_intercompany_matrix, export_intercompany_matrix_preset, export_intercompany_result_table, router, @@ -20,8 +22,8 @@ from src.models.enums import AuditAction, AuditSection, PeriodQuarter, Statement from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, - IntercompanyMatrixManualIn, IntercompanyMatrixOut, + IntercompanyMatrixSelectionIn, IntercompanyResultAmountOut, IntercompanyResultArticleOut, IntercompanyResultTableOut, @@ -34,6 +36,7 @@ from src.services.intercompany_matrix_export import ( build_matrix_workbook, build_result_table_workbook, ) +from src.services.osv_export import MONEY_FORMAT def _info(*, mode: str = "MANUAL", rule_id: UUID | None = None) -> MatrixExportInfo: @@ -92,13 +95,21 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold osv_entry_ids_credit=[], ), ], + total_debet={company_a: "100.25", company_b: "20.00"}, + total_credit={company_a: "20.00", company_b: "90.00"}, ) workbook = load_workbook(BytesIO(build_matrix_workbook(matrix, _info()))) sheet = workbook.active assert sheet is not None assert sheet.title == "Шахматка" - assert [cell.value for cell in sheet[10]] == ["Дебет ↓ / Кредит →", "Показатель", "=Компания А", "Компания Б"] + assert [cell.value for cell in sheet[10]] == [ + "Дебет ↓ / Кредит →", + "Показатель", + "=Компания А", + "Компания Б", + "Дебет", + ] assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[10]) assert sheet["C10"].data_type == "s" assert sheet["A1"].value == "Шахматка внутригрупповых операций" @@ -114,7 +125,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold assert sheet["A11"].value == "=Компания А" assert sheet["A11"].data_type == "s" assert sheet["A11"].alignment.horizontal == "center" - assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18"} + assert {str(merged) for merged in sheet.merged_cells.ranges} == {"A11:A14", "A15:A18", "E11:E14", "E15:E18"} assert [sheet.cell(row, 2).value for row in range(11, 15)] == list(METRICS) assert [sheet.cell(row, 2).value for row in range(15, 19)] == list(METRICS) assert all(sheet.cell(row, 2).alignment.horizontal == "center" for row in range(11, 19)) @@ -134,6 +145,10 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold if (row, column) != (11, 4) ) assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0] + assert Decimal(str(sheet["E11"].value)) == Decimal("100.25") + assert Decimal(str(sheet["E15"].value)) == Decimal("20") + assert [sheet.cell(19, column).value for column in range(1, 6)] == ["Кредит", None, 20, 90, None] + assert all(sheet[cell].number_format == MONEY_FORMAT for cell in ("E11", "E15", "C19", "D19")) assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19)) assert sheet.freeze_panes == "C11" for column, first_row in ((3, 11), (4, 11), (3, 15), (4, 15)): @@ -174,6 +189,8 @@ def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str, osv_entry_ids_credit=[], ) ], + total_debet={company_a: amount, company_b: "0"}, + total_credit={company_a: "0", company_b: "0"}, ) sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, _info(mode="ALL")))).active @@ -206,25 +223,37 @@ async def test_export_routes_use_matrix_calculation() -> None: osv_entry_ids_credit=[], ) ], + total_debet={company_a.id: "0", company_b.id: "0"}, + total_credit={company_a.id: "0", company_b.id: "0"}, ) calls = [] async def build_export(*args: object, **kwargs: object) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: calls.append((args, kwargs)) - return matrix, _info(mode=kwargs["mode"], rule_id=kwargs.get("rule_id")) + mode = ( + "PRESET" + if kwargs.get("rule_id") + else "MANUAL" + if kwargs.get("debit_account_ids") or kwargs.get("credit_account_ids") + else "ALL" + ) + return matrix, _info(mode=mode, rule_id=kwargs.get("rule_id")) service = SimpleNamespace(build_export=build_export) audit = SimpleNamespace(record=AsyncMock()) - selection = IntercompanyMatrixManualIn(debit_account_ids=[account_id], credit_account_ids=[account_id]) + selection = IntercompanyMatrixSelectionIn(debit_account_ids=[account_id], credit_account_ids=[account_id]) - all_response = await export_intercompany_matrix_all(version_id, service, audit) - manual_response = await export_intercompany_matrix_manual(version_id, selection, service, audit) + all_response = await export_intercompany_matrix(version_id, service, audit) + manual_response = await export_intercompany_matrix(version_id, service, audit, selection) preset_response = await export_intercompany_matrix_preset(version_id, account_id, service, audit) assert calls == [ - ((version_id,), {"mode": "ALL"}), - ((version_id,), {"mode": "MANUAL", "debit_account_ids": [account_id], "credit_account_ids": [account_id]}), - ((version_id,), {"mode": "PRESET", "rule_id": account_id}), + ((version_id,), {"debit_account_ids": [], "credit_account_ids": [], "legal_entity_ids": []}), + ( + (version_id,), + {"debit_account_ids": [account_id], "credit_account_ids": [account_id], "legal_entity_ids": []}, + ), + ((version_id,), {"rule_id": account_id}), ] for response, expected_info in ( (all_response, _info(mode="ALL")), @@ -262,6 +291,7 @@ async def test_export_routes_use_matrix_calculation() -> None: "mode": "ALL", "debit_account_ids": [], "credit_account_ids": [], + "legal_entity_ids": [], "cells": 1, }, }, @@ -276,6 +306,7 @@ async def test_export_routes_use_matrix_calculation() -> None: "mode": "MANUAL", "debit_account_ids": [str(account_id)], "credit_account_ids": [str(account_id)], + "legal_entity_ids": [], "cells": 1, }, }, @@ -290,6 +321,7 @@ async def test_export_routes_use_matrix_calculation() -> None: "mode": "PRESET", "debit_account_ids": [], "credit_account_ids": [], + "legal_entity_ids": [], "rule_id": str(account_id), "rule_no": 6, "rule_version_no": 3, @@ -299,11 +331,50 @@ async def test_export_routes_use_matrix_calculation() -> None: ] paths = {(route.path, tuple(sorted(route.methods))) for route in router.routes} - assert ("/consolidations/versions/{version_id}/intercompany-matrix/all/export", ("GET",)) in paths - assert ("/consolidations/versions/{version_id}/intercompany-matrix/manual/export", ("POST",)) in paths + assert ("/consolidations/versions/{version_id}/intercompany-matrix/export", ("POST",)) in paths assert ("/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export", ("GET",)) in paths +@pytest.mark.parametrize( + ("selection", "mode"), + [ + ({}, "ALL"), + ({"debit_account_ids": [UUID("11111111-1111-1111-1111-111111111111")]}, "MANUAL"), + ({"legal_entity_ids": [UUID("11111111-1111-1111-1111-111111111111")]}, "ALL"), + ], +) +async def test_unified_export_uses_empty_side_as_all(selection: dict, mode: str) -> None: + version_id = UUID("22222222-2222-2222-2222-222222222222") + request = IntercompanyMatrixSelectionIn(**selection) + matrix = IntercompanyMatrixOut(companies=[], cells=[]) + service = SimpleNamespace(build_export=AsyncMock(return_value=(matrix, _info(mode=mode)))) + audit = SimpleNamespace(record=AsyncMock()) + + response = await export_intercompany_matrix(version_id, service, audit, request) + + assert response.status_code == http_status.HTTP_200_OK + service.build_export.assert_awaited_once_with( + version_id, + debit_account_ids=request.debit_account_ids, + credit_account_ids=request.credit_account_ids, + legal_entity_ids=request.legal_entity_ids, + ) + assert audit.record.await_args.kwargs["after_value"]["mode"] == mode + assert audit.record.await_args.kwargs["after_value"]["legal_entity_ids"] == [ + str(entity_id) for entity_id in request.legal_entity_ids + ] + + +def test_export_header_marks_empty_side_as_all() -> None: + matrix = IntercompanyMatrixOut(companies=[], cells=[]) + info = replace(_info(), credit_account_codes=()) + + sheet = load_workbook(BytesIO(build_matrix_workbook(matrix, info))).active + + assert sheet["B7"].value == "62.01" + assert sheet["B8"].value == "Все балансовые счета" + + async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None: version_id = UUID("11111111-1111-1111-1111-111111111111") service = SimpleNamespace( @@ -316,7 +387,7 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon monkeypatch.setattr("src.api.endpoints.consolidations.intercompany_matrix.build_matrix_workbook", fail_workbook) with pytest.raises(ValueError, match="xlsx failed"): - await export_intercompany_matrix_all(version_id, service, audit) + await export_intercompany_matrix(version_id, service, audit) audit.record.assert_not_awaited() diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index bf47e58..32a2f1a 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -89,10 +89,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/consolidations/versions/{version_id}/vgo-assignments/distribute": {"get"}, "/api/v1/consolidations/versions/{version_id}/vgo-assignments/rule": {"put", "delete"}, "/api/v1/consolidations/versions/{version_id}/vgo-assignments/exclude": {"put"}, - "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"}, - "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, - "/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": {"post"}, + "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/export": {"post"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"}, diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index 5c626e4..a9bd3c8 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -147,12 +147,11 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD - **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` ### Requirement: Аудит экспорта шахматки -Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и -`GET …/intercompany-matrix/preset/{rule_id}/export` +Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export` SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, `before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, -credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и +credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и `rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, `MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`. diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 61a9661..92166f9 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -175,17 +175,20 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` ### Requirement: Шахматка по сводной ОСВ версии -`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и -`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual` -(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии -и её периметра. ALL включает балансовые счета (`is_off_balance = false`) вида `A`/`AP` для дебета и `P`/`AP` для -кредита; забалансовые счета (001–012) в ALL MUST NOT входить. MANUAL принимает обязательные непустые -`debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета по иерархии и оставляет -подходящий вид; забалансовые счета в MANUAL допускаются. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный -вид — 422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL +`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает +`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст, +для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается +по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL, +хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу +без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида +`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку. +Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`. +Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ. +ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. -Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары +Ответ SHALL содержать `companies[] {id, name}` для всего периметра, `total_debet` и `total_credit` +с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, `exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`. Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, @@ -197,6 +200,8 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count` SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы `osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны. +`total_debet` суммирует `amount_a` ячеек по `company_a_id`, `total_credit` — `amount_b` по `company_b_id`. +Для ЮЛ без подходящих строк обе суммы равны `"0.00"`. #### Scenario: Пустой периметр и отсутствие ОСВ - **WHEN** у версии нет членов периметра @@ -231,11 +236,17 @@ SHALL считать исходные строки ОСВ пары с `account_i - **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) ### Requirement: XLSX шахматки -`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать -те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем +`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX +по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет +`legal_entity_ids` из запроса и использует +`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`. + +Ответ — XLSX с именем `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. +Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debet` по каждому ЮЛ строки; +под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки. #### Scenario: Экспорт выбранных счетов - **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 -- 2.47.2 From a48a8f496c67dfd91a36a89bb41996f4169b9086 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Thu, 5 Nov 2026 16:22:17 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=AE=D0=9B=20=D0=B2=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=83=D1=8E=D1=89=D0=B5=D0=B9=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D0=B5=20=D1=88=D0=B0=D1=85=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/schemas/consolidations.py | 1 - api/src/services/intercompany_matrix.py | 8 -------- api/src/services/intercompany_matrix_export.py | 5 ----- api/tests/test_api_intercompany_matrix.py | 7 +++---- api/tests/test_intercompany_matrix.py | 5 +---- api/tests/test_intercompany_matrix_export.py | 9 ++------- openspec/specs/consolidations-api/spec.md | 7 +++---- 7 files changed, 9 insertions(+), 33 deletions(-) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index a4472e3..65c22ab 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -702,7 +702,6 @@ class IntercompanyResultTableOut(BaseModel): companies: list[IntercompanyMatrixCompanyOut] articles: list[IntercompanyResultArticleOut] - company_totals: list[IntercompanyResultAmountOut] grand_total: Decimal @field_validator("grand_total") diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 4b34224..84cda3d 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -374,17 +374,9 @@ def calculate_result_table( group_total=sum((item.amount for item in company_amounts), ZERO), ) ) - company_totals = [ - IntercompanyResultAmountOut( - company_id=company.id, - amount=sum((article.amounts[index].amount for article in articles), ZERO), - ) - for index, company in enumerate(companies) - ] return IntercompanyResultTableOut( companies=list(companies), articles=articles, - company_totals=company_totals, grand_total=sum((article.group_total for article in articles), ZERO), ) diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 7a4ca7c..76d8a1e 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -213,11 +213,6 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result summary_row = header_row + len(result.articles) + 1 _text(sheet, summary_row, 1, "Итого") sheet.cell(summary_row, 1).font = Font(bold=True) - totals = {item.company_id: item.amount for item in result.company_totals} - for column, company in enumerate(result.companies, start=2): - cell = sheet.cell(summary_row, column, totals[company.id]) - cell.number_format = MONEY_FORMAT - cell.font = Font(bold=True) grand_total = sheet.cell(summary_row, total_column, result.grand_total) grand_total.number_format = MONEY_FORMAT grand_total.font = Font(bold=True) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 1490c34..38fcd78 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -730,7 +730,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"] assert receivables["group_total"] == "100.25" assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables) - assert sorted(item["amount"] for item in data["company_totals"]) == ["0.00", "100.25"] + assert "company_totals" not in data assert data["grand_total"] == "100.25" sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"] assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [ @@ -744,9 +744,8 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"]) summary_row = 8 + len(data["articles"]) assert sheet.cell(summary_row, 1).value == "Итого" - totals = {item["company_id"]: Decimal(item["amount"]) for item in data["company_totals"]} - for col, company in enumerate(data["companies"], start=2): - assert Decimal(str(sheet.cell(summary_row, col).value)) == totals[company["id"]] + for col in range(2, len(data["companies"]) + 2): + assert sheet.cell(summary_row, col).value is None assert Decimal(str(sheet.cell(summary_row, len(data["companies"]) + 2).value)) == Decimal(data["grand_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"])} diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index 618492d..fe7d2ae 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -500,10 +500,7 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: assert isinstance(result, IntercompanyResultTableOut) response = result assert response.articles[0].amounts[0].company_id == company_a - assert [(item.company_id, item.amount) for item in result.company_totals] == [ - (company_a, Decimal("140.00")), - (company_b, Decimal("110.00")), - ] + assert "company_totals" not in result.model_dump(mode="json") assert result.grand_total == Decimal("250.00") diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index d3b50ab..5ee8b3f 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -410,10 +410,6 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: group_total="120.25", ) ], - company_totals=[ - IntercompanyResultAmountOut(company_id=version_id, amount="100.25"), - IntercompanyResultAmountOut(company_id=other_id, amount="20.00"), - ], grand_total="120.25", ) info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2) @@ -435,7 +431,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: 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.cell(9, column).value for column in range(1, 5)] == ["Итого", 100.25, 20, 120.25] + assert [sheet.cell(9, column).value for column in range(1, 5)] == ["Итого", None, None, 120.25] assert sheet.freeze_panes == "B8" assert audit.record.await_args.kwargs == { "action": AuditAction.EXPORT, @@ -456,7 +452,7 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte service = SimpleNamespace( build_result_export=AsyncMock( return_value=( - IntercompanyResultTableOut(companies=[], articles=[], company_totals=[], grand_total="0"), + IntercompanyResultTableOut(companies=[], articles=[], grand_total="0"), info, ) ) @@ -479,7 +475,6 @@ def test_result_table_workbook_rejects_article_without_company_amount() -> None: result = IntercompanyResultTableOut( companies=[company], articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")], - company_totals=[IntercompanyResultAmountOut(company_id=company.id, amount="0")], grand_total="0", ) with pytest.raises(KeyError): diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index ccece5c..5586911 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -253,12 +253,11 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: Результирующая таблица ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` -(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], company_totals[], grand_total}`. Компании — все члены периметра +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], grand_total}`. Компании — все члены периметра по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`. Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. -`company_totals[] {company_id, amount}` SHALL содержать сумму всех статей для каждого ЮЛ в порядке `companies[]`; `grand_total` — сумму `group_total` всех статей. Итоги считаются из округлённых значений ответа. `amount`, `group_total` и `grand_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` (например, `"0.00"`). @@ -267,7 +266,6 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` #### Scenario: Компания без ОСВ - **WHEN** в периметре есть компания без подходящих строк ОСВ - **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"` -- **AND** её итог в `company_totals` — строка `"0.00"` #### Scenario: Нет обязательного счёта - **WHEN** в плане счетов отсутствует счёт 58.03 @@ -276,7 +274,8 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: XLSX результирующей таблицы ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`, -из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит `company_totals` и `grand_total`. +из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит только `grand_total` в колонке группы; +ячейки этой строки под отдельными ЮЛ пусты. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы «руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx`. Ошибки — как у JSON-маршрута: невидимая или -- 2.47.2 From 6faa4fb325beb215268c45630f7333b4bd885fd8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 16:42:52 +0400 Subject: [PATCH 4/6] =?UTF-8?q?AURORA-1756:=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20#218=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B0,=20to?= =?UTF-8?q?tal=5Fdebit,=20change=20OpenSpec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - grand_total результирующей таблицы убран из API и xlsx: аналитик 03.11 попросил убрать итог снизу, сумма ДЗ + КЗ + ФВ + займов смысла не имеет - total_debet переименовано в total_debit, как во всём API - итоги матрицы квантуются до двух знаков ("0.00" и при одном ЮЛ) - правки openspec/specs перенесены в change intercompany-matrix-totals - README: фильтр ЮЛ и итоги Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/schemas/consolidations.py | 20 ++-- api/src/services/intercompany_matrix.py | 12 +-- .../services/intercompany_matrix_export.py | 9 +- api/tests/test_api_intercompany_matrix.py | 15 +-- api/tests/test_intercompany_matrix.py | 10 +- api/tests/test_intercompany_matrix_export.py | 12 +-- .../intercompany-matrix-totals/design.md | 18 ++++ .../intercompany-matrix-totals/proposal.md | 31 ++++++ .../specs/audit-trail/spec.md | 19 ++++ .../specs/consolidated-osv/spec.md | 99 +++++++++++++++++++ .../specs/consolidations-api/spec.md | 25 +++++ .../intercompany-matrix-totals/tasks.md | 12 +++ openspec/specs/audit-trail/spec.md | 5 +- openspec/specs/consolidated-osv/spec.md | 33 +++---- openspec/specs/consolidations-api/spec.md | 9 +- 16 files changed, 254 insertions(+), 77 deletions(-) create mode 100644 openspec/changes/intercompany-matrix-totals/design.md create mode 100644 openspec/changes/intercompany-matrix-totals/proposal.md create mode 100644 openspec/changes/intercompany-matrix-totals/specs/audit-trail/spec.md create mode 100644 openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md create mode 100644 openspec/changes/intercompany-matrix-totals/specs/consolidations-api/spec.md create mode 100644 openspec/changes/intercompany-matrix-totals/tasks.md diff --git a/README.md b/README.md index 6b03ba0..5034e9a 100644 --- a/README.md +++ b/README.md @@ -938,7 +938,7 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` (аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации): `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. -Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые. `one_sided` — ровно одна сумма +Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые; `legal_entity_ids` сужает обе оси и итоги до выбранных ЮЛ. Итоги `total_debit` («Итого Дт», строка) и `total_credit` («Итого Кт», столбец) — по ID ЮЛ, в xlsx — колонка «Дебет» и строка «Кредит»; у результирующей таблицы итога по статьям нет. `one_sided` — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии `consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 65c22ab..9680736 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -667,8 +667,18 @@ class IntercompanyMatrixOut(BaseModel): companies: list[IntercompanyMatrixCompanyOut] cells: list[IntercompanyMatrixCellOut] - total_debet: dict[uuid.UUID, Decimal] = Field(default_factory=dict) - total_credit: dict[uuid.UUID, Decimal] = Field(default_factory=dict) + total_debit: dict[uuid.UUID, Decimal] = Field( + default_factory=dict, description="Итого Дт: сумма amount_a ячеек строки компании (ключ — ID ЮЛ)" + ) + total_credit: dict[uuid.UUID, Decimal] = Field( + default_factory=dict, description="Итого Кт: сумма amount_b ячеек столбца компании (ключ — ID ЮЛ)" + ) + + @field_validator("total_debit", "total_credit") + @classmethod + def _two_decimals(cls, value: dict[uuid.UUID, Decimal]) -> dict[uuid.UUID, Decimal]: + """Итоги в JSON всегда с двумя знаками, в том числе нулевые.""" + return {key: amount.quantize(CENT, rounding=ROUND_HALF_UP) for key, amount in value.items()} class IntercompanyResultAmountOut(BaseModel): @@ -702,9 +712,3 @@ class IntercompanyResultTableOut(BaseModel): companies: list[IntercompanyMatrixCompanyOut] articles: list[IntercompanyResultArticleOut] - grand_total: Decimal - - @field_validator("grand_total") - @classmethod - def _two_decimals(cls, value: Decimal) -> Decimal: - return value.quantize(CENT, rounding=ROUND_HALF_UP) diff --git a/api/src/services/intercompany_matrix.py b/api/src/services/intercompany_matrix.py index 84cda3d..1bbbadd 100644 --- a/api/src/services/intercompany_matrix.py +++ b/api/src/services/intercompany_matrix.py @@ -265,13 +265,13 @@ def calculate_matrix( osv_entry_ids_credit=credit_ids, ) ) - total_debet = {company_id: ZERO for company_id in company_ids} + total_debit = {company_id: ZERO for company_id in company_ids} total_credit = {company_id: ZERO for company_id in company_ids} for cell in cells: - total_debet[cell.company_a_id] += cell.amount_a + total_debit[cell.company_a_id] += cell.amount_a total_credit[cell.company_b_id] += cell.amount_b return IntercompanyMatrixOut( - companies=list(company_info), cells=cells, total_debet=total_debet, total_credit=total_credit + companies=list(company_info), cells=cells, total_debit=total_debit, total_credit=total_credit ) @@ -374,11 +374,7 @@ def calculate_result_table( group_total=sum((item.amount for item in company_amounts), ZERO), ) ) - return IntercompanyResultTableOut( - companies=list(companies), - articles=articles, - grand_total=sum((article.group_total for article in articles), ZERO), - ) + return IntercompanyResultTableOut(companies=list(companies), articles=articles) def _pair_row_out(row: ConsolidatedRow) -> EliminationPairRowOut: diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 76d8a1e..6915552 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -99,7 +99,7 @@ def _write_matrix_totals( end_row=start_row + len(METRICS) - 1, end_column=total_column, ) - debit_total = sheet.cell(start_row, total_column, matrix.total_debet[company.id]) + debit_total = sheet.cell(start_row, total_column, matrix.total_debit[company.id]) debit_total.number_format = MONEY_FORMAT debit_total.font = Font(bold=True) debit_total.alignment = Alignment(horizontal="right", vertical="center") @@ -210,13 +210,6 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result total.number_format = MONEY_FORMAT total.font = Font(bold=True) - summary_row = header_row + len(result.articles) + 1 - _text(sheet, summary_row, 1, "Итого") - sheet.cell(summary_row, 1).font = Font(bold=True) - grand_total = sheet.cell(summary_row, total_column, result.grand_total) - grand_total.number_format = MONEY_FORMAT - grand_total.font = Font(bold=True) - sheet.freeze_panes = "B8" buffer = BytesIO() workbook.save(buffer) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 38fcd78..4109f94 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -99,7 +99,7 @@ async def test_empty_perimeter(auth_client: AuthClientFactory, seeded: AsyncSess world = await _world(seeded, members=0) response = await _request(await auth_client(KA_RF), world.version.id, "matrix", selection) assert response.status_code == OK, response.text - assert response.json() == {"companies": [], "cells": [], "total_debet": {}, "total_credit": {}} + assert response.json() == {"companies": [], "cells": [], "total_debit": {}, "total_credit": {}} @pytest.mark.parametrize("suffix", ROUTES) @@ -162,7 +162,7 @@ async def test_company_filter_applies_to_matrix_and_export( data = response.json() assert [company["id"] for company in data["companies"]] == selected assert len(data["cells"]) == 2 # noqa: PLR2004 - одна направленная ячейка на каждую сторону пары - assert set(data["total_debet"]) == set(data["total_credit"]) == set(selected) + assert set(data["total_debit"]) == set(data["total_credit"]) == set(selected) sheet = load_workbook(BytesIO(export.content))["Шахматка"] assert [sheet.cell(10, column).value for column in range(3, 6)] == [ world.members[0].name, @@ -619,7 +619,7 @@ async def test_all_excludes_off_balance_accounts_but_manual_can_select_them( assert manual_response.status_code == OK, manual_response.text cell = next(c for c in manual_response.json()["cells"] if c["company_a_id"] == str(world.members[0].id)) assert Decimal(cell["amount_a"]) == Decimal("100") - assert manual_response.json()["total_debet"][str(world.members[0].id)] == "100.00" + assert manual_response.json()["total_debit"][str(world.members[0].id)] == "100.00" assert all(amount == "0.00" for amount in manual_response.json()["total_credit"].values()) @@ -730,8 +730,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, assert sorted(item["amount"] for item in receivables["amounts"]) == ["0.00", "100.25"] assert receivables["group_total"] == "100.25" assert all(article["group_total"] == "0.00" for article in data["articles"] if article != receivables) - assert "company_totals" not in data - assert data["grand_total"] == "100.25" + assert {"company_totals", "grand_total"}.isdisjoint(data) 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"] @@ -742,11 +741,7 @@ async def test_result_table_export_matches_json(auth_client: AuthClientFactory, 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"]) - summary_row = 8 + len(data["articles"]) - assert sheet.cell(summary_row, 1).value == "Итого" - for col in range(2, len(data["companies"]) + 2): - assert sheet.cell(summary_row, col).value is None - assert Decimal(str(sheet.cell(summary_row, len(data["companies"]) + 2).value)) == Decimal(data["grand_total"]) + assert sheet.max_row == 7 + len(data["articles"]) event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])} diff --git a/api/tests/test_intercompany_matrix.py b/api/tests/test_intercompany_matrix.py index fe7d2ae..0730453 100644 --- a/api/tests/test_intercompany_matrix.py +++ b/api/tests/test_intercompany_matrix.py @@ -111,9 +111,9 @@ def test_matrix_totals_accumulate_each_company_and_side() -> None: matrix.MatrixSelection(frozenset({debit_account}), frozenset({credit_account}), "closing"), ) - assert result.total_debet == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")} + assert result.total_debit == {company_a: Decimal("120"), company_b: Decimal("0"), company_c: Decimal("5")} assert result.total_credit == {company_a: Decimal("3"), company_b: Decimal("70"), company_c: Decimal("10")} - assert result.model_dump(mode="json")["total_debet"][str(company_a)] == "120.00" + assert result.model_dump(mode="json")["total_debit"][str(company_a)] == "120.00" @pytest.mark.parametrize( @@ -343,7 +343,8 @@ async def test_all_excludes_off_balance_accounts_only(monkeypatch: pytest.Monkey assert (credit_only.cells[0].amount_a, credit_only.cells[0].amount_b) == (Decimal("30"), Decimal("40")) assert [company.id for company in filtered.companies] == [company_a] assert filtered.cells == [] - assert filtered.total_debet == filtered.total_credit == {company_a: Decimal("0")} + assert filtered.total_debit == filtered.total_credit == {company_a: Decimal("0")} + assert filtered.model_dump(mode="json")["total_debit"] == {str(company_a): "0.00"} with pytest.raises(matrix.DomainValidationError) as error: await service.build_accounts(uuid.uuid4(), legal_entity_ids=[uuid.uuid4()]) assert error.value.code == "ENTITY_NOT_IN_PERIMETER" @@ -500,8 +501,7 @@ def test_result_table_uses_fixed_articles_and_group_totals() -> None: assert isinstance(result, IntercompanyResultTableOut) response = result assert response.articles[0].amounts[0].company_id == company_a - assert "company_totals" not in result.model_dump(mode="json") - assert result.grand_total == Decimal("250.00") + assert {"company_totals", "grand_total"}.isdisjoint(result.model_dump(mode="json")) def test_pairs_use_signed_sums_and_leave_negative_rows_unpaired() -> None: diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 5ee8b3f..2320e02 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -95,7 +95,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold osv_entry_ids_credit=[], ), ], - total_debet={company_a: "100.25", company_b: "20.00"}, + total_debit={company_a: "100.25", company_b: "20.00"}, total_credit={company_a: "20.00", company_b: "90.00"}, ) @@ -189,7 +189,7 @@ def test_xlsx_highlights_one_sided_amount_but_not_two_zero_balances(amount: str, osv_entry_ids_credit=[], ) ], - total_debet={company_a: amount, company_b: "0"}, + total_debit={company_a: amount, company_b: "0"}, total_credit={company_a: "0", company_b: "0"}, ) @@ -223,7 +223,7 @@ async def test_export_routes_use_matrix_calculation() -> None: osv_entry_ids_credit=[], ) ], - total_debet={company_a.id: "0", company_b.id: "0"}, + total_debit={company_a.id: "0", company_b.id: "0"}, total_credit={company_a.id: "0", company_b.id: "0"}, ) calls = [] @@ -410,7 +410,6 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: group_total="120.25", ) ], - grand_total="120.25", ) info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2) service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info))) @@ -431,7 +430,7 @@ async def test_result_table_export_matches_result_and_records_audit() -> None: 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.cell(9, column).value for column in range(1, 5)] == ["Итого", None, None, 120.25] + assert sheet.max_row == 8 # noqa: PLR2004 — под статьями нет строки «Итого» assert sheet.freeze_panes == "B8" assert audit.record.await_args.kwargs == { "action": AuditAction.EXPORT, @@ -452,7 +451,7 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte service = SimpleNamespace( build_result_export=AsyncMock( return_value=( - IntercompanyResultTableOut(companies=[], articles=[], grand_total="0"), + IntercompanyResultTableOut(companies=[], articles=[]), info, ) ) @@ -475,7 +474,6 @@ def test_result_table_workbook_rejects_article_without_company_amount() -> None: result = IntercompanyResultTableOut( companies=[company], articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")], - grand_total="0", ) with pytest.raises(KeyError): build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)) diff --git a/openspec/changes/intercompany-matrix-totals/design.md b/openspec/changes/intercompany-matrix-totals/design.md new file mode 100644 index 0000000..1a299e0 --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/design.md @@ -0,0 +1,18 @@ +# Design + +## Решения + +- **Один маршрут вместо ALL/MANUAL.** Режим выводится из тела: обе стороны пусты — ALL, иначе MANUAL; пресет — + отдельный маршрут, как раньше. Одна пустая сторона = все балансовые счета этой стороны. +- **Фильтр ЮЛ на бэке.** `legal_entity_ids` сужает периметр до расчёта: оси, ячейки и итоги считаются только между + выбранными ЮЛ (DOC-221277: «оба итога должны пересчитываться по выбранным компаниям»). +- **Итоги — словарь по ID ЮЛ.** `total_debit[id]` — сумма `amount_a` строки, `total_credit[id]` — сумма `amount_b` + столбца; значения квантуются до двух знаков в схеме (иначе пустая выборка из одного ЮЛ дала бы `"0"`). + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-11-06 | Поле `total_debet` переименовано в `total_debit` (ревью #218, M-1) | во всём API — `debit` (`debit_account_ids`, `osv_entry_ids_debit`); фронт поле ещё не читает | нулевая: переименование до первого потребителя | +| 2026-11-06 | Итоги квантуются валидатором схемы (M-2) | спека обещает `"0.00"`; при одном ЮЛ ячеек нет и сумма — `Decimal("0")` | минимальная | +| 2026-11-06 | `grand_total` результирующей таблицы убран из API и XLSX (I-1, решение владельца в чате) | аналитик 03.11 попросил убрать итог снизу; сумма ДЗ + КЗ + ФВ + займов смысла не имеет; в DOC-221277 итоговой строки нет | если заказчику понадобится итог — отдельная задача с правилом | diff --git a/openspec/changes/intercompany-matrix-totals/proposal.md b/openspec/changes/intercompany-matrix-totals/proposal.md new file mode 100644 index 0000000..bdb270d --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/proposal.md @@ -0,0 +1,31 @@ +# Proposal + +## Why + +Итоги шахматки («Итого Дт» справа, «Итого Кт» снизу) считал фронт (PR #158, решение владельца 22.10.2026: агрегаты +отдаёт бэк). Аналитик задал правило итогов (DOC-221277, «Итоги шахматки», 03.11.2026) и попросил пересчитывать итоги +по выбранным в фильтре компаниям — значит, выбор компаний должен уйти в запрос. AURORA-1756. + +## What Changes + +- Единый `POST …/intercompany-matrix` и `POST …/intercompany-matrix/export` вместо `GET …/all`, `POST …/manual` и их + выгрузок: тело `{debit_account_ids?, credit_account_ids?, legal_entity_ids?}`; пустая сторона — все балансовые + счета; пустой `legal_entity_ids` — весь периметр. **BREAKING** для клиента фронта (переводится в AURORA-1757, + сливается одновременно). +- В ответе матрицы `total_debit` и `total_credit` — итог по каждому ЮЛ, строки с двумя знаками. +- В XLSX шахматки — колонка «Дебет» справа и строка «Кредит» снизу. +- Аудит выгрузки сохраняет `legal_entity_ids`. +- Результирующая таблица: итога по всем статьям нет (аналитик, 03.11.2026). + +## Capabilities + +### Modified Capabilities + +- `consolidated-osv`: шахматка и её XLSX. +- `audit-trail`: payload аудита выгрузки шахматки. +- `consolidations-api`: результирующая таблица без итоговой строки. + +## Impact + +`arm-core/api`: маршруты, схемы и сервис шахматки, XLSX. Клиент фронта (`web/src/services/api/intercompanyMatrix.ts`) +переводится в AURORA-1757. PRESET не меняется. diff --git a/openspec/changes/intercompany-matrix-totals/specs/audit-trail/spec.md b/openspec/changes/intercompany-matrix-totals/specs/audit-trail/spec.md new file mode 100644 index 0000000..3b584ec --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/specs/audit-trail/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Аудит экспорта шахматки +Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export` +SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, +`object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, +`before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, +credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и +`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, +`MANUAL` или `PRESET`, массивы счетов и ЮЛ — ID из запроса (счета в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка +сводной ОСВ продолжает писать собственное событие `EXPORT`. + +#### Scenario: Две выгрузки +- **WHEN** пользователь дважды выгружает ту же шахматку +- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек + +#### Scenario: Выгрузка пресета +- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export` +- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек diff --git a/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..4977449 --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md @@ -0,0 +1,99 @@ +## MODIFIED Requirements + +### Requirement: Шахматка по сводной ОСВ версии +`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает +`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст, +для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается +по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL, +хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу +без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида +`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку. +Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`. +Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ. +ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL +отвечать одинаковым 404 `NOT_FOUND`. + +Ответ SHALL содержать `companies[] {id, name}` для периметра (при непустом `legal_entity_ids` — только выбранные ЮЛ в порядке периметра), `total_debit` и `total_credit` +с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары +разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, +`exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`. +Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, +`amount_b` — сумма `closing_credit` строк B с контрагентом A по кредитовым. Расхождение — модуль разности; +процент — расхождение / меньшую сумму × 100 или null при нуле. `exceeds_threshold` SHALL быть true при точном +проценте > 5, до округления; суммы и процент в JSON SHALL быть десятичными строками с двумя знаками после +запятой (`ROUND_HALF_UP`). `one_sided` (пара без встречной стороны) SHALL быть true тогда и только тогда, когда +ровно одна из сумм `amount_a`, `amount_b` равна нулю (знак ненулевой суммы не важен); наличие строк ОСВ с нулевым +сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count` +SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы +`osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны. +`total_debit` («Итого Дт», колонка справа) суммирует `amount_a` ячеек строки по `company_a_id`, `total_credit` +(«Итого Кт», строка снизу) — `amount_b` ячеек столбца по `company_b_id`; ключи — ID всех ЮЛ из `companies`. +Диагональ не участвует, отрицательные суммы входят со знаком, суммы ячеек точные (`numeric(18,2)`). Для ЮЛ без +подходящих строк, в том числе когда в выборке одно ЮЛ, итог равен `"0.00"` (правило аналитика, DOC-221277 +«Итоги шахматки», 03.11.2026). + +#### Scenario: Пустой периметр и отсутствие ОСВ +- **WHEN** у версии нет членов периметра +- **THEN** `companies = []`, `cells = []`, `total_debit = {}`, `total_credit = {}`; если компании есть, но ОСВ по выбранным счетам нет, ячейки присутствуют с нулевыми суммами + +#### Scenario: Только одна сторона пары +- **WHEN** у A есть строка ОСВ с контрагентом B и ненулевым сальдо, а у B встречной строки нет +- **THEN** для `(A, B)` `one_sided = true`, `variance_percent = null`, соответствующий массив `osv_entry_ids_*` пуст + +#### Scenario: Встречная строка с нулевым сальдо +- **WHEN** у A по B сальдо 100 000 000, а у B по A есть строка ОСВ с нулевым сальдо (одни обороты) +- **THEN** `one_sided = true`, `exceeds_threshold = false`, пара подсвечивается + +#### Scenario: Обе стороны нулевые +- **WHEN** у A и у B есть строки ОСВ друг на друга, но обе суммы равны 0 +- **THEN** `one_sided = false`, `exceeds_threshold = false`, пара не подсвечивается + +#### Scenario: Отрицательное сальдо +- **WHEN** у A по B сальдо −50, у B по A — 0 +- **THEN** `one_sided = true` + +#### Scenario: Забалансовые счета +- **WHEN** у A по B есть сальдо на счёте 001 (`is_off_balance = true`) +- **THEN** в режиме ALL оно в суммы не входит; в MANUAL с выбранным счётом 001 — входит + +#### Scenario: Счёт не сопоставлен +- **WHEN** у пары есть строка свода с `account_id = null` и двумя `entry_ids` +- **THEN** она не входит в суммы, `skipped_osv_entry_count` обеих направленных ячеек пары увеличивается на 2 + +#### Scenario: Эталон «Русский бисквит» +- **WHEN** матрица строится по ОСВ РБ и Пекарни Счастливочной из эталонных фикстур +- **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) + +#### Scenario: Итоги Дт и Кт +- **WHEN** в периметре A, B, C; `amount_a` ячеек (A, B) = 100 и (A, C) = 20, `amount_b` ячеек (A, B) = 70 и (C, B) = 5 +- **THEN** `total_debit[A] = "120.00"`, `total_credit[B] = "75.00"` + +#### Scenario: Фильтр по ЮЛ +- **WHEN** запрос с `legal_entity_ids = [A, B]` при периметре A, B, C +- **THEN** `companies` и обе оси — только A и B, ячейки и итоги не учитывают C; ID вне периметра — 422 `ENTITY_NOT_IN_PERIMETER` + +#### Scenario: Пустая одна сторона +- **WHEN** `debit_account_ids = [62]`, `credit_account_ids` пуст +- **THEN** дебет — счёт 62 с потомками, кредит — все балансовые счета вида `P`/`AP`; режим выгрузки и аудита — `MANUAL` + +### Requirement: XLSX шахматки +`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX +по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет +`legal_entity_ids` из запроса и использует +`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`. + +Ответ — XLSX с именем +`intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, +выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для +каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. +Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debit` по каждому ЮЛ строки (ячейка объединена +на строки пары); под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки; ячейка на +пересечении пустая. Значения — те же, что в JSON. + +#### Scenario: Экспорт выбранных счетов +- **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 +- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, шапка содержит выбранные счета, ячейки процента показывают два знака и символ `%` без повторного масштабирования + +#### Scenario: Подсветка пары без встречной стороны +- **WHEN** выгружается ALL, у пары `amount_a = 100000000`, `amount_b = 0` +- **THEN** все строки пары залиты цветом порога, в шапке «Режим» и «Счета дебета/кредита» — «Все балансовые счета» diff --git a/openspec/changes/intercompany-matrix-totals/specs/consolidations-api/spec.md b/openspec/changes/intercompany-matrix-totals/specs/consolidations-api/spec.md new file mode 100644 index 0000000..042fbbc --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/specs/consolidations-api/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Результирующая таблица ВГО +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра +по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`. +Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, +долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра +из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. +`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` +(например, `"0.00"`). +Итоговой строки по всем статьям (по компании или по группе) в ответе нет: суммы разных статей не складываются +(решение аналитика 03.11.2026, согласовано с заказчиком). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. + +#### Scenario: Компания без ОСВ +- **WHEN** в периметре есть компания без подходящих строк ОСВ +- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0.00"` + +#### Scenario: Нет обязательного счёта +- **WHEN** в плане счетов отсутствует счёт 58.03 +- **THEN** ответ 422 `RESULT_ACCOUNTS_MISSING`, сообщение содержит `58.03` + +#### Scenario: Нет итога по статьям +- **WHEN** запрашивается результирующая таблица +- **THEN** в ответе нет `grand_total` и итогов по компании через все статьи; в XLSX под статьями нет строки «Итого» diff --git a/openspec/changes/intercompany-matrix-totals/tasks.md b/openspec/changes/intercompany-matrix-totals/tasks.md new file mode 100644 index 0000000..e62850b --- /dev/null +++ b/openspec/changes/intercompany-matrix-totals/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +## 1. Бэк (PR arm-core #218, автор — Потапова) + +- [x] 1.1 Единый маршрут матрицы и выгрузки, `legal_entity_ids`, `ENTITY_NOT_IN_PERIMETER` +- [x] 1.2 `total_debit` / `total_credit` в ответе и XLSX +- [x] 1.3 Аудит выгрузки с `legal_entity_ids` +- [x] 1.4 Ревью: `grand_total` убран, переименование `total_debit`, квантование итогов, change вместо правки `specs/` + +## 2. Фронт (AURORA-1757, отдельный PR) + +- [ ] 2.1 Клиент на единый маршрут, итоги из API, фильтр «Компании» в запрос diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index a9bd3c8..5c626e4 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -147,11 +147,12 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD - **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` ### Requirement: Аудит экспорта шахматки -Каждый успешный `POST …/intercompany-matrix/export` и `GET …/intercompany-matrix/preset/{rule_id}/export` +Каждый успешный `GET …/intercompany-matrix/all/export`, `POST …/intercompany-matrix/manual/export` и +`GET …/intercompany-matrix/preset/{rule_id}/export` SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, `before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, -credit_account_ids, legal_entity_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и +credit_account_ids, cells}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и `rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`, `MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`. diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 92166f9..61a9661 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -175,20 +175,17 @@ filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<кварт — одну строку с `Q2`; `audit_event(EXPORT)` с `after_value.rows = 3` ### Requirement: Шахматка по сводной ОСВ версии -`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix` принимает -`{debit_account_ids?: UUID[], credit_account_ids?: UUID[], legal_entity_ids?: UUID[]}`. Если список стороны отсутствует или пуст, -для неё выбираются все подходящие балансовые счета (`is_off_balance = false`); непустой список раскрывается -по иерархии и может содержать забалансовые счета. Пустые списки обеих сторон эквивалентны режиму ALL, -хотя бы один непустой список — MANUAL. Маршрут (`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу -без сохранения в БД, из строк сводной ОСВ версии и её периметра. Для пустой стороны выбираются счета вида -`A`/`AP` для дебета или `P`/`AP` для кредита; забалансовые счета (001–012) MUST NOT входить в такую выборку. -Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный вид — 422 `ACCOUNT_KIND_MISMATCH`. -Пустой `legal_entity_ids` выбирает все ЮЛ периметра; непустой ограничивает обе оси матрицы и суммы выбранными ЮЛ. -ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. Невидимая и несуществующая версия SHALL +`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/all` и +`POST /api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual` +(`CONSOLIDATION_VIEW`, policy) SHALL рассчитывать матрицу без сохранения в БД, из строк сводной ОСВ версии +и её периметра. ALL включает балансовые счета (`is_off_balance = false`) вида `A`/`AP` для дебета и `P`/`AP` для +кредита; забалансовые счета (001–012) в ALL MUST NOT входить. MANUAL принимает обязательные непустые +`debit_account_ids: UUID[]` и `credit_account_ids: UUID[]`, раскрывает выбранные счета по иерархии и оставляет +подходящий вид; забалансовые счета в MANUAL допускаются. Неизвестный счёт SHALL давать 422 `ACCOUNT_NOT_FOUND`, неверный +вид — 422 `ACCOUNT_KIND_MISMATCH`; пустой массив — 422 валидации тела. Невидимая и несуществующая версия SHALL отвечать одинаковым 404 `NOT_FOUND`. -Ответ SHALL содержать `companies[] {id, name}` для всего периметра, `total_debet` и `total_credit` -с суммой по каждому ЮЛ (ключ — ID ЮЛ, значение — десятичная строка с двумя знаками), и `cells[]` для каждой упорядоченной пары +Ответ SHALL содержать `companies[] {id, name}` для всего периметра и `cells[]` для каждой упорядоченной пары разных компаний: `company_a_id`, `company_b_id`, `amount_a`, `amount_b`, `variance_amount`, `variance_percent`, `exceeds_threshold`, `one_sided`, `skipped_osv_entry_count`, `osv_entry_ids_debit`, `osv_entry_ids_credit`. Для пары `(A, B)` `amount_a` — сумма `closing_debit` строк A с контрагентом B по дебетовым счетам, @@ -200,8 +197,6 @@ ID вне периметра SHALL давать 422 `ENTITY_NOT_IN_PERIMETER`. сальдо на признак не влияет. Пара подсвечивается, если `exceeds_threshold` или `one_sided`. `skipped_osv_entry_count` SHALL считать исходные строки ОСВ пары с `account_id = null` на обеих сторонах; они не входят в суммы. Массивы `osv_entry_ids_debit/credit` SHALL содержать ID исходных записей соответствующей стороны. -`total_debet` суммирует `amount_a` ячеек по `company_a_id`, `total_credit` — `amount_b` по `company_b_id`. -Для ЮЛ без подходящих строк обе суммы равны `"0.00"`. #### Scenario: Пустой периметр и отсутствие ОСВ - **WHEN** у версии нет членов периметра @@ -236,17 +231,11 @@ SHALL считать исходные строки ОСВ пары с `account_i - **THEN** суммы сравниваются с эталоном только для точных пар счетов и компаний; общая сумма элиминации эталона не приравнивается к минимуму по паре в API (решение владельца 08.10.2026) ### Requirement: XLSX шахматки -`POST …/intercompany-matrix/export` принимает то же тело, что единый JSON-маршрут, и строит XLSX -по той же выборке ЮЛ и счетов. В шапке пустая сторона обозначается «Все балансовые счета»; аудит сохраняет -`legal_entity_ids` из запроса и использует -`mode = ALL`, когда пусты обе стороны, иначе `mode = MANUAL`. - -Ответ — XLSX с именем +`GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL использовать +те же режимы, тело и права, что и соответствующие JSON-маршруты. Ответ — XLSX с именем `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. -Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debet` по каждому ЮЛ строки; -под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки. #### Scenario: Экспорт выбранных счетов - **WHEN** пользователь выгружает MANUAL для ГК «Русский бисквит» за 2024 Q1, версия 1 diff --git a/openspec/specs/consolidations-api/spec.md b/openspec/specs/consolidations-api/spec.md index 5586911..15004ba 100644 --- a/openspec/specs/consolidations-api/spec.md +++ b/openspec/specs/consolidations-api/spec.md @@ -253,13 +253,12 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: Результирующая таблица ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table` -(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[], grand_total}`. Компании — все члены периметра +(`CONSOLIDATION_VIEW`, policy) SHALL возвращать `{companies[], articles[]}`. Компании — все члены периметра по имени; статья содержит `code`, `name`, `amounts[] {company_id, amount}` и `group_total`. Пять фиксированных статей §6: дебиторская и кредиторская задолженность, финансовые вложения, долгосрочные и краткосрочные займы. Сумма компании SHALL считаться по всем остальным компаниям периметра из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям. -`grand_total` — сумму `group_total` всех статей. Итоги считаются из округлённых значений ответа. -`amount`, `group_total` и `grand_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` +`amount` и `group_total` SHALL возвращаться строками с двумя знаками после округления `ROUND_HALF_UP` (например, `"0.00"`). Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов. @@ -274,9 +273,7 @@ account, status, missing_quarters, document_ids}`: `document_type` и `account` ### Requirement: XLSX результирующей таблицы ВГО `GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`, -из того же расчёта сводной ОСВ версии. Последняя строка «Итого» содержит только `grand_total` в колонке группы; -ячейки этой строки под отдельными ЮЛ пусты. -Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы +из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы «руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v.xlsx`. Ошибки — как у JSON-маршрута: невидимая или несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`. -- 2.47.2 From df4e171cc53e121d8773566d7773509fdcb88d45 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 17:00:46 +0400 Subject: [PATCH 5/6] =?UTF-8?q?AURORA-1756:=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20xlsx=20=E2=80=94=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=94=D1=82=C2=BB=20/=20=C2=AB=D0=98=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=9A=D1=82=C2=BB,=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B2=20DOC-221277?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/services/intercompany_matrix_export.py | 4 ++-- api/tests/test_api_intercompany_matrix.py | 2 +- api/tests/test_intercompany_matrix_export.py | 4 ++-- openspec/changes/intercompany-matrix-totals/proposal.md | 2 +- .../intercompany-matrix-totals/specs/consolidated-osv/spec.md | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5034e9a..8f268f7 100644 --- a/README.md +++ b/README.md @@ -938,7 +938,7 @@ API: список счетов `GET /directories/accounts`, правила ве `GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export` (аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации): `GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`. -Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые; `legal_entity_ids` сужает обе оси и итоги до выбранных ЮЛ. Итоги `total_debit` («Итого Дт», строка) и `total_credit` («Итого Кт», столбец) — по ID ЮЛ, в xlsx — колонка «Дебет» и строка «Кредит»; у результирующей таблицы итога по статьям нет. `one_sided` — ровно одна сумма +Пустой список стороны выбирает все подходящие балансовые счета (без 001–012); непустой список может включать и забалансовые; `legal_entity_ids` сужает обе оси и итоги до выбранных ЮЛ. Итоги `total_debit` («Итого Дт», строка) и `total_credit` («Итого Кт», столбец) — по ID ЮЛ, в xlsx — те же колонка и строка; у результирующей таблицы итога по статьям нет. `one_sided` — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651). `GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии `consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату diff --git a/api/src/services/intercompany_matrix_export.py b/api/src/services/intercompany_matrix_export.py index 6915552..2a22051 100644 --- a/api/src/services/intercompany_matrix_export.py +++ b/api/src/services/intercompany_matrix_export.py @@ -105,7 +105,7 @@ def _write_matrix_totals( debit_total.alignment = Alignment(horizontal="right", vertical="center") credit_total_row = data_start_row + len(matrix.companies) * len(METRICS) - _text(sheet, credit_total_row, 1, "Кредит") + _text(sheet, credit_total_row, 1, "Итого Кт") sheet.cell(credit_total_row, 1).font = Font(bold=True) for column, company in enumerate(matrix.companies, start=3): credit_total = sheet.cell(credit_total_row, column, matrix.total_credit[company.id]) @@ -129,7 +129,7 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) for column, company in enumerate(matrix.companies, start=3): _text(sheet, table_header_row, column, company.name) sheet.column_dimensions[get_column_letter(column)].width = 26 - _text(sheet, table_header_row, debit_total_column, "Дебет") + _text(sheet, table_header_row, debit_total_column, "Итого Дт") sheet.column_dimensions[get_column_letter(debit_total_column)].width = 26 sheet.row_dimensions[table_header_row].height = 36 for header in sheet[table_header_row]: diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 4109f94..2b131f5 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -167,7 +167,7 @@ async def test_company_filter_applies_to_matrix_and_export( assert [sheet.cell(10, column).value for column in range(3, 6)] == [ world.members[0].name, world.members[1].name, - "Дебет", + "Итого Дт", ] audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() assert audit.after_value["legal_entity_ids"] == selected diff --git a/api/tests/test_intercompany_matrix_export.py b/api/tests/test_intercompany_matrix_export.py index 2320e02..a9b764e 100644 --- a/api/tests/test_intercompany_matrix_export.py +++ b/api/tests/test_intercompany_matrix_export.py @@ -108,7 +108,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold "Показатель", "=Компания А", "Компания Б", - "Дебет", + "Итого Дт", ] assert all(cell.alignment.horizontal == "center" and cell.alignment.vertical == "center" for cell in sheet[10]) assert sheet["C10"].data_type == "s" @@ -147,7 +147,7 @@ def test_matrix_export_has_three_rows_per_debit_company_and_highlights_threshold assert [sheet.cell(row, 3).value for row in range(15, 19)] == [0, 20, 20, 0] assert Decimal(str(sheet["E11"].value)) == Decimal("100.25") assert Decimal(str(sheet["E15"].value)) == Decimal("20") - assert [sheet.cell(19, column).value for column in range(1, 6)] == ["Кредит", None, 20, 90, None] + assert [sheet.cell(19, column).value for column in range(1, 6)] == ["Итого Кт", None, 20, 90, None] assert all(sheet[cell].number_format == MONEY_FORMAT for cell in ("E11", "E15", "C19", "D19")) assert all(sheet.cell(row, 3).fill.fgColor.rgb != THRESHOLD_FILL.fgColor.rgb for row in range(15, 19)) assert sheet.freeze_panes == "C11" diff --git a/openspec/changes/intercompany-matrix-totals/proposal.md b/openspec/changes/intercompany-matrix-totals/proposal.md index bdb270d..2b1d581 100644 --- a/openspec/changes/intercompany-matrix-totals/proposal.md +++ b/openspec/changes/intercompany-matrix-totals/proposal.md @@ -13,7 +13,7 @@ счета; пустой `legal_entity_ids` — весь периметр. **BREAKING** для клиента фронта (переводится в AURORA-1757, сливается одновременно). - В ответе матрицы `total_debit` и `total_credit` — итог по каждому ЮЛ, строки с двумя знаками. -- В XLSX шахматки — колонка «Дебет» справа и строка «Кредит» снизу. +- В XLSX шахматки — колонка «Итого Дт» справа и строка «Итого Кт» снизу. - Аудит выгрузки сохраняет `legal_entity_ids`. - Результирующая таблица: итога по всем статьям нет (аналитик, 03.11.2026). diff --git a/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md b/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md index 4977449..406b741 100644 --- a/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md +++ b/openspec/changes/intercompany-matrix-totals/specs/consolidated-osv/spec.md @@ -86,8 +86,8 @@ SHALL считать исходные строки ОСВ пары с `account_i `intercompany-matrix_<ГК>_<год>-<квартал>_v.xlsx`, шапкой (ГК, период, версия, единицы, режим, выбранные счета; для ALL — «Все балансовые счета») и строками разницы, дебета, кредита и процента расхождения для каждой пары. Пара SHALL заливаться цветом порога, если `exceeds_threshold` или `one_sided` — так же, как в API. -Справа от колонок компаний SHALL быть колонка «Дебет» с `total_debit` по каждому ЮЛ строки (ячейка объединена -на строки пары); под матрицей SHALL быть строка «Кредит» с `total_credit` по каждому ЮЛ колонки; ячейка на +Справа от колонок компаний SHALL быть колонка «Итого Дт» с `total_debit` по каждому ЮЛ строки (ячейка объединена +на строки пары); под матрицей SHALL быть строка «Итого Кт» с `total_credit` по каждому ЮЛ колонки; ячейка на пересечении пустая. Значения — те же, что в JSON. #### Scenario: Экспорт выбранных счетов -- 2.47.2 From e71c14bc2ca19801ab15c3c1c1af1f3cb5038926 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 17:08:28 +0400 Subject: [PATCH 6/6] =?UTF-8?q?AURORA-1756:=20=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20=D0=AE=D0=9B=20?= =?UTF-8?q?=E2=80=94=20=5Fsource=20=D0=B1=D0=B5=D1=80=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=83=20=D0=B8=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=B2=D1=83=D1=85=20=D0=AE=D0=9B=20(?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=20=D0=BD=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B5=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=82=D1=80=D1=91=D1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/test_api_intercompany_matrix.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/tests/test_api_intercompany_matrix.py b/api/tests/test_api_intercompany_matrix.py index 2b131f5..44050cd 100644 --- a/api/tests/test_api_intercompany_matrix.py +++ b/api/tests/test_api_intercompany_matrix.py @@ -70,7 +70,8 @@ async def _request(client: AsyncClient, version_id: uuid.UUID, suffix: str, sele async def _source( session: AsyncSession, world: World, *, credit: bool, amount: str, account: str | None = None ) -> uuid.UUID: - owner, counterparty = reversed(world.members) if credit else world.members + pair = world.members[:2] + owner, counterparty = reversed(pair) if credit else pair code = account or ("60.01" if credit else "62.01") document = await make_confirmed_osv( session, @@ -163,6 +164,7 @@ async def test_company_filter_applies_to_matrix_and_export( assert [company["id"] for company in data["companies"]] == selected assert len(data["cells"]) == 2 # noqa: PLR2004 - одна направленная ячейка на каждую сторону пары assert set(data["total_debit"]) == set(data["total_credit"]) == set(selected) + assert data["total_debit"] == {selected[0]: "100.00", selected[1]: "0.00"} sheet = load_workbook(BytesIO(export.content))["Шахматка"] assert [sheet.cell(10, column).value for column in range(3, 6)] == [ world.members[0].name, -- 2.47.2