Замечания ревью #159: проверки, общие реквизиты книги, change OpenSpec
- маршрут result-table/export в EXPECTED_ROUTES, ruff format - общие VersionExportInfo, _write_details, _group_name, _xlsx_response вместо копий шапки и ответа - сумма компании в книге без молчаливого 0 (KeyError при пропуске в расчёте) - своё описание 422 у маршрутов результирующей таблицы в OpenAPI - тесты: 422 RESULT_ACCOUNTS_MISSING для экспорта без события EXPORT, схема OpenAPI экспорта - требование перенесено из openspec/specs в change intercompany-result-table-export (+ audit-trail), README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ea51cb35c2
commit
63d6c1a017
@ -870,7 +870,8 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
|
|||||||
|
|
||||||
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
|
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
|
||||||
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
|
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
|
||||||
`GET …/intercompany-matrix/result-table`. Контракт для фронта —
|
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
|
||||||
|
(аудит `EXPORT`, `report = "intercompany_result_table"`). Контракт для фронта —
|
||||||
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
|
`../docs/dev/frontend-api-guide.md` §5.8; требования — в существующих спецификациях
|
||||||
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
|
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
|
||||||
Расчёт читает сводную ОСВ версии, результат не хранится.
|
Расчёт читает сводную ОСВ версии, результат не хранится.
|
||||||
|
|||||||
@ -23,19 +23,26 @@ ERRORS: dict[int | str, dict[str, Any]] = {
|
|||||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"},
|
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "Версия не найдена либо недоступна"},
|
||||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"},
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {"model": ErrorDetail, "description": "Неверный выбор счетов"},
|
||||||
}
|
}
|
||||||
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
RESULT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||||
EXPORT_RESPONSES = {
|
|
||||||
**ERRORS,
|
**ERRORS,
|
||||||
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}},
|
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||||
|
"model": ErrorDetail,
|
||||||
|
"description": "В плане счетов нет счетов статей результирующей таблицы",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
XLSX_CONTENT: dict[int | str, dict[str, Any]] = {
|
||||||
|
status.HTTP_200_OK: {"content": {XLSX_MEDIA_TYPE: {"schema": {"type": "string", "format": "binary"}}}}
|
||||||
|
}
|
||||||
|
EXPORT_RESPONSES = {**ERRORS, **XLSX_CONTENT}
|
||||||
|
RESULT_EXPORT_RESPONSES = {**RESULT_ERRORS, **XLSX_CONTENT}
|
||||||
|
|
||||||
|
|
||||||
async def _xlsx_response(matrix: IntercompanyMatrixOut, info: MatrixExportInfo) -> Response:
|
def _xlsx_response(content: bytes, filename: str, *, ascii_fallback: str) -> Response:
|
||||||
content = await run_in_threadpool(build_matrix_workbook, matrix, info)
|
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=XLSX_MEDIA_TYPE,
|
media_type=XLSX_MEDIA_TYPE,
|
||||||
headers={"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-matrix.xlsx")},
|
headers={"Content-Disposition": content_disposition(filename, ascii_fallback=ascii_fallback)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -47,7 +54,8 @@ async def _export_response(
|
|||||||
*,
|
*,
|
||||||
selection: IntercompanyMatrixManualIn | None = None,
|
selection: IntercompanyMatrixManualIn | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
response = await _xlsx_response(matrix, info)
|
content = await run_in_threadpool(build_matrix_workbook, matrix, info)
|
||||||
|
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-matrix.xlsx")
|
||||||
await audit.record(
|
await audit.record(
|
||||||
action=AuditAction.EXPORT,
|
action=AuditAction.EXPORT,
|
||||||
section=AuditSection.EXPORT,
|
section=AuditSection.EXPORT,
|
||||||
@ -149,7 +157,7 @@ async def export_intercompany_matrix_manual(
|
|||||||
"/{version_id}/intercompany-matrix/result-table",
|
"/{version_id}/intercompany-matrix/result-table",
|
||||||
response_model=IntercompanyResultTableOut,
|
response_model=IntercompanyResultTableOut,
|
||||||
dependencies=VIEW,
|
dependencies=VIEW,
|
||||||
responses=ERRORS,
|
responses=RESULT_ERRORS,
|
||||||
summary="Результирующая таблица ВГО",
|
summary="Результирующая таблица ВГО",
|
||||||
)
|
)
|
||||||
async def get_intercompany_result_table(
|
async def get_intercompany_result_table(
|
||||||
@ -164,7 +172,7 @@ async def get_intercompany_result_table(
|
|||||||
"/{version_id}/intercompany-matrix/result-table/export",
|
"/{version_id}/intercompany-matrix/result-table/export",
|
||||||
response_class=Response,
|
response_class=Response,
|
||||||
dependencies=VIEW,
|
dependencies=VIEW,
|
||||||
responses=EXPORT_RESPONSES,
|
responses=RESULT_EXPORT_RESPONSES,
|
||||||
summary="Скачать результирующую таблицу ВГО в XLSX",
|
summary="Скачать результирующую таблицу ВГО в XLSX",
|
||||||
)
|
)
|
||||||
async def export_intercompany_result_table(
|
async def export_intercompany_result_table(
|
||||||
@ -174,13 +182,7 @@ async def export_intercompany_result_table(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
result, info = await service.build_result_export(version_id)
|
result, info = await service.build_result_export(version_id)
|
||||||
content = await run_in_threadpool(build_result_table_workbook, result, info)
|
content = await run_in_threadpool(build_result_table_workbook, result, info)
|
||||||
response = Response(
|
response = _xlsx_response(content, info.filename, ascii_fallback="intercompany-result-table.xlsx")
|
||||||
content=content,
|
|
||||||
media_type=XLSX_MEDIA_TYPE,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-result-table.xlsx")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await audit.record(
|
await audit.record(
|
||||||
action=AuditAction.EXPORT,
|
action=AuditAction.EXPORT,
|
||||||
section=AuditSection.EXPORT,
|
section=AuditSection.EXPORT,
|
||||||
|
|||||||
@ -76,30 +76,34 @@ class MatrixSelection:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MatrixExportInfo:
|
class VersionExportInfo:
|
||||||
|
"""Реквизиты версии в шапке и имени книги."""
|
||||||
|
|
||||||
group_name: str
|
group_name: str
|
||||||
year: int
|
year: int
|
||||||
quarter: PeriodQuarter
|
quarter: PeriodQuarter
|
||||||
version_no: int
|
version_no: int
|
||||||
|
|
||||||
|
def _filename(self, prefix: str) -> str:
|
||||||
|
return f"{prefix}_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MatrixExportInfo(VersionExportInfo):
|
||||||
mode: Literal["ALL", "MANUAL"]
|
mode: Literal["ALL", "MANUAL"]
|
||||||
debit_account_codes: tuple[str, ...]
|
debit_account_codes: tuple[str, ...]
|
||||||
credit_account_codes: tuple[str, ...]
|
credit_account_codes: tuple[str, ...]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
return self._filename("intercompany-matrix")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ResultTableExportInfo:
|
class ResultTableExportInfo(VersionExportInfo):
|
||||||
group_name: str
|
|
||||||
year: int
|
|
||||||
quarter: PeriodQuarter
|
|
||||||
version_no: int
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
return f"intercompany-result-table_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
return self._filename("intercompany-result-table")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -326,19 +330,22 @@ class IntercompanyMatrixService:
|
|||||||
) -> tuple[IntercompanyResultTableOut, ResultTableExportInfo]:
|
) -> tuple[IntercompanyResultTableOut, ResultTableExportInfo]:
|
||||||
"""Таблица и реквизиты книги из одного расчёта свода."""
|
"""Таблица и реквизиты книги из одного расчёта свода."""
|
||||||
built = await self._build_result_table(version_id)
|
built = await self._build_result_table(version_id)
|
||||||
group_name = (
|
|
||||||
await self._session.execute(
|
|
||||||
select(CompanyGroup.name).where(CompanyGroup.id == built.consolidation.company_group_id)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
info = ResultTableExportInfo(
|
info = ResultTableExportInfo(
|
||||||
group_name, built.consolidation.year, built.consolidation.quarter, built.version.version_no
|
group_name=await self._group_name(built.consolidation),
|
||||||
|
year=built.consolidation.year,
|
||||||
|
quarter=built.consolidation.quarter,
|
||||||
|
version_no=built.version.version_no,
|
||||||
)
|
)
|
||||||
return built.table, info
|
return built.table, info
|
||||||
|
|
||||||
async def _build_result_table(
|
async def _group_name(self, consolidation: Consolidation) -> str:
|
||||||
self, version_id: uuid.UUID
|
return (
|
||||||
) -> ResultTableBuild:
|
await self._session.execute(
|
||||||
|
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
async def _build_result_table(self, version_id: uuid.UUID) -> ResultTableBuild:
|
||||||
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
|
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
|
||||||
accounts = await directory_repo.list_accounts(self._session)
|
accounts = await directory_repo.list_accounts(self._session)
|
||||||
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
|
companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()]
|
||||||
@ -371,14 +378,9 @@ class IntercompanyMatrixService:
|
|||||||
matrix, version, consolidation, accounts = await self._build_accounts(
|
matrix, version, consolidation, accounts = await self._build_accounts(
|
||||||
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
|
version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids
|
||||||
)
|
)
|
||||||
group_name = (
|
|
||||||
await self._session.execute(
|
|
||||||
select(CompanyGroup.name).where(CompanyGroup.id == consolidation.company_group_id)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
by_id = {account.id: account.code for account in accounts}
|
by_id = {account.id: account.code for account in accounts}
|
||||||
info = MatrixExportInfo(
|
info = MatrixExportInfo(
|
||||||
group_name=group_name,
|
group_name=await self._group_name(consolidation),
|
||||||
year=consolidation.year,
|
year=consolidation.year,
|
||||||
quarter=consolidation.quarter,
|
quarter=consolidation.quarter,
|
||||||
version_no=version.version_no,
|
version_no=version.version_no,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from openpyxl.utils import get_column_letter
|
|||||||
from openpyxl.worksheet.worksheet import Worksheet
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut
|
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||||
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo
|
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo, VersionExportInfo
|
||||||
from src.services.osv_export import MONEY_FORMAT
|
from src.services.osv_export import MONEY_FORMAT
|
||||||
|
|
||||||
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
|
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
|
||||||
@ -33,16 +33,17 @@ def _skipped_comment(sheet: Worksheet, row: int, column: int, count: int) -> Non
|
|||||||
sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА")
|
sheet.cell(row, column).comment = Comment(f"Пропущено строк ОСВ без сопоставленного счёта: {count}", "АРМ КА")
|
||||||
|
|
||||||
|
|
||||||
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
|
def _write_details(
|
||||||
_text(sheet, 1, 1, "Шахматка внутригрупповых операций")
|
sheet: Worksheet, title: str, info: VersionExportInfo, units: str, extra: tuple[tuple[str, str], ...] = ()
|
||||||
|
) -> None:
|
||||||
|
"""Заголовок и строки «реквизит — значение» над таблицей книги."""
|
||||||
|
_text(sheet, 1, 1, title)
|
||||||
details = (
|
details = (
|
||||||
("ГК", info.group_name),
|
("ГК", info.group_name),
|
||||||
("Период", f"{info.year}-{info.quarter.value}"),
|
("Период", f"{info.year}-{info.quarter.value}"),
|
||||||
("Версия", f"v{info.version_no}"),
|
("Версия", f"v{info.version_no}"),
|
||||||
("Единицы", "руб.; расхождение — %"),
|
("Единицы", units),
|
||||||
("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"),
|
*extra,
|
||||||
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
|
||||||
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
|
||||||
)
|
)
|
||||||
for row, (label, value) in enumerate(details, start=2):
|
for row, (label, value) in enumerate(details, start=2):
|
||||||
_text(sheet, row, 1, label)
|
_text(sheet, row, 1, label)
|
||||||
@ -50,6 +51,20 @@ def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
|
|||||||
sheet.cell(row, 1).font = Font(bold=True)
|
sheet.cell(row, 1).font = Font(bold=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_context(sheet: Worksheet, info: MatrixExportInfo) -> None:
|
||||||
|
_write_details(
|
||||||
|
sheet,
|
||||||
|
"Шахматка внутригрупповых операций",
|
||||||
|
info,
|
||||||
|
"руб.; расхождение — %",
|
||||||
|
(
|
||||||
|
("Режим", "Все счета" if info.mode == "ALL" else "Выбранные счета"),
|
||||||
|
("Счета дебета", ", ".join(info.debit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
||||||
|
("Счета кредита", ", ".join(info.credit_account_codes) if info.mode == "MANUAL" else "Все счета"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None:
|
def _write_values(sheet: Worksheet, row: int, column: int, cell: IntercompanyMatrixCellOut) -> None:
|
||||||
for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)):
|
for offset, amount in enumerate((cell.variance_amount, cell.amount_a, cell.amount_b)):
|
||||||
sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT
|
sheet.cell(row + offset, column, amount).number_format = MONEY_FORMAT
|
||||||
@ -122,16 +137,7 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result
|
|||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
assert sheet is not None
|
assert sheet is not None
|
||||||
sheet.title = "Результирующая таблица"
|
sheet.title = "Результирующая таблица"
|
||||||
_text(sheet, 1, 1, "Результирующая таблица ВГО")
|
_write_details(sheet, "Результирующая таблица ВГО", info, "руб.")
|
||||||
for row, label, value in (
|
|
||||||
(2, "ГК", info.group_name),
|
|
||||||
(3, "Период", f"{info.year}-{info.quarter.value}"),
|
|
||||||
(4, "Версия", f"v{info.version_no}"),
|
|
||||||
(5, "Единицы", "руб."),
|
|
||||||
):
|
|
||||||
_text(sheet, row, 1, label)
|
|
||||||
_text(sheet, row, 2, value)
|
|
||||||
sheet.cell(row, 1).font = Font(bold=True)
|
|
||||||
|
|
||||||
header_row = 7
|
header_row = 7
|
||||||
_text(sheet, header_row, 1, "Статья ВГО")
|
_text(sheet, header_row, 1, "Статья ВГО")
|
||||||
@ -153,7 +159,7 @@ def build_result_table_workbook(result: IntercompanyResultTableOut, info: Result
|
|||||||
_text(sheet, row, 1, article.name)
|
_text(sheet, row, 1, article.name)
|
||||||
amounts = {amount.company_id: amount.amount for amount in article.amounts}
|
amounts = {amount.company_id: amount.amount for amount in article.amounts}
|
||||||
for column, company in enumerate(result.companies, start=2):
|
for column, company in enumerate(result.companies, start=2):
|
||||||
sheet.cell(row, column, amounts.get(company.id, 0)).number_format = MONEY_FORMAT
|
sheet.cell(row, column, amounts[company.id]).number_format = MONEY_FORMAT
|
||||||
total = sheet.cell(row, total_column, article.group_total)
|
total = sheet.cell(row, total_column, article.group_total)
|
||||||
total.number_format = MONEY_FORMAT
|
total.number_format = MONEY_FORMAT
|
||||||
total.font = Font(bold=True)
|
total.font = Font(bold=True)
|
||||||
|
|||||||
@ -220,15 +220,17 @@ async def test_invalid_account_selection(
|
|||||||
assert response.json()["detail"]["code"] == code
|
assert response.json()["detail"]["code"] == code
|
||||||
|
|
||||||
|
|
||||||
async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
@pytest.mark.parametrize("suffix", ["result-table", "result-table/export"])
|
||||||
|
async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: AsyncSession, suffix: str) -> None:
|
||||||
world = await _world(seeded)
|
world = await _world(seeded)
|
||||||
# Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков.
|
# Меняем код в тестовой транзакции, сохраняя FK существующих правил и потомков.
|
||||||
world.accounts["58.03"].code = "58.03-missing"
|
world.accounts["58.03"].code = "58.03-missing"
|
||||||
await seeded.flush()
|
await seeded.flush()
|
||||||
response = await _request(await auth_client(KA_RF), world.version.id, "result-table", world.selection)
|
response = await _request(await auth_client(KA_RF), world.version.id, suffix, world.selection)
|
||||||
assert response.status_code == UNPROCESSABLE, response.text
|
assert response.status_code == UNPROCESSABLE, response.text
|
||||||
assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING"
|
assert response.json()["detail"]["code"] == "RESULT_ACCOUNTS_MISSING"
|
||||||
assert "58.03" in response.json()["detail"]["message"]
|
assert "58.03" in response.json()["detail"]["message"]
|
||||||
|
assert not (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).all()
|
||||||
|
|
||||||
|
|
||||||
async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||||
|
|||||||
@ -31,6 +31,7 @@ from src.services.intercompany_matrix_export import (
|
|||||||
METRICS,
|
METRICS,
|
||||||
THRESHOLD_FILL,
|
THRESHOLD_FILL,
|
||||||
build_matrix_workbook,
|
build_matrix_workbook,
|
||||||
|
build_result_table_workbook,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -271,13 +272,14 @@ async def test_result_table_export_matches_result_and_records_audit() -> None:
|
|||||||
sheet = load_workbook(BytesIO(response.body))["Результирующая таблица"]
|
sheet = load_workbook(BytesIO(response.body))["Результирующая таблица"]
|
||||||
assert [sheet[f"B{row}"].value for row in range(2, 6)] == [info.group_name, "2024-Q3", "v2", "руб."]
|
assert [sheet[f"B{row}"].value for row in range(2, 6)] == [info.group_name, "2024-Q3", "v2", "руб."]
|
||||||
assert [sheet.cell(7, column).value for column in range(1, 5)] == [
|
assert [sheet.cell(7, column).value for column in range(1, 5)] == [
|
||||||
"Статья ВГО", "=Компания А", "Компания Б", "Итого по группе"
|
"Статья ВГО",
|
||||||
|
"=Компания А",
|
||||||
|
"Компания Б",
|
||||||
|
"Итого по группе",
|
||||||
]
|
]
|
||||||
assert sheet["B7"].data_type == "s"
|
assert sheet["B7"].data_type == "s"
|
||||||
assert [sheet.cell(8, column).value for column in range(1, 5)] == [
|
assert [sheet.cell(8, column).value for column in range(1, 5)] == ["Дебиторская задолженность", 100.25, 20, 120.25]
|
||||||
"Дебиторская задолженность", 100.25, 20, 120.25
|
assert all(sheet.cell(8, column).number_format == "#,##0.00" for column in range(2, 5))
|
||||||
]
|
|
||||||
assert all(sheet.cell(8, column).number_format == '#,##0.00' for column in range(2, 5))
|
|
||||||
assert sheet.freeze_panes == "B8"
|
assert sheet.freeze_panes == "B8"
|
||||||
assert audit.record.await_args.kwargs == {
|
assert audit.record.await_args.kwargs == {
|
||||||
"action": AuditAction.EXPORT,
|
"action": AuditAction.EXPORT,
|
||||||
@ -309,3 +311,13 @@ async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pyte
|
|||||||
with pytest.raises(ValueError, match="xlsx failed"):
|
with pytest.raises(ValueError, match="xlsx failed"):
|
||||||
await export_intercompany_result_table(version_id, service, audit)
|
await export_intercompany_result_table(version_id, service, audit)
|
||||||
audit.record.assert_not_awaited()
|
audit.record.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_table_workbook_rejects_article_without_company_amount() -> None:
|
||||||
|
company = IntercompanyMatrixCompanyOut(id=UUID("11111111-1111-1111-1111-111111111111"), name="Компания А")
|
||||||
|
result = IntercompanyResultTableOut(
|
||||||
|
companies=[company],
|
||||||
|
articles=[IntercompanyResultArticleOut(code="receivables", name="ДЗ", amounts=[], group_total="0")],
|
||||||
|
)
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
build_result_table_workbook(result, ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2))
|
||||||
|
|||||||
@ -80,6 +80,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
|||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
|
||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"},
|
||||||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
|
||||||
|
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"get"},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
|
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
|
||||||
@ -191,6 +192,17 @@ def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any])
|
|||||||
assert not {"limit", "offset"} & names
|
assert not {"limit", "offset"} & names
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_table_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
|
||||||
|
"""Экспорт результирующей таблицы: 200 — xlsx, 422 — нет счетов статей, а не «неверный выбор счетов»."""
|
||||||
|
base = "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table"
|
||||||
|
export = schema["paths"][f"{base}/export"]["get"]["responses"]
|
||||||
|
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
assert export["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
|
||||||
|
for responses in (export, schema["paths"][base]["get"]["responses"]):
|
||||||
|
assert responses["404"]["description"] == "Версия не найдена либо недоступна"
|
||||||
|
assert responses["422"]["description"] == "В плане счетов нет счетов статей результирующей таблицы"
|
||||||
|
|
||||||
|
|
||||||
def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None:
|
def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None:
|
||||||
"""T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422."""
|
"""T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422."""
|
||||||
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]
|
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]
|
||||||
|
|||||||
32
openspec/changes/intercompany-result-table-export/design.md
Normal file
32
openspec/changes/intercompany-result-table-export/design.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Реализация — PR RSHB/arm-core#159. Результирующая таблица ВГО (§6) — чистая функция от сводной ОСВ версии и периметра
|
||||||
|
(`calculate_result_table`); в БД не хранится (DECISION-5). Выгрузка повторяет устройство выгрузки шахматки
|
||||||
|
(`intercompany_matrix_export.py`, change `intercompany-matrix`).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. JSON и xlsx строятся одним методом сервиса `_build_result_table` (`build_with_members` → `calculate_result_table`),
|
||||||
|
поэтому состав и порядок компаний, статьи и `group_total` совпадают; параметров у таблицы нет.
|
||||||
|
2. Книга: лист «Результирующая таблица», шапка (ГК, период, версия, единицы «руб.»), строка заголовка «Статья ВГО» /
|
||||||
|
компании / «Итого по группе», суммы — `Decimal` с форматом `#,##0.00`, закреплены заголовок и первая колонка.
|
||||||
|
Имя файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx` (RFC 5987, запасное ASCII-имя).
|
||||||
|
3. Аудит `EXPORT` пишется после построения книги: ошибка расчёта (404, 422) или сборки книги события не оставляет.
|
||||||
|
4. Реквизиты версии в шапке и имени файла общие для шахматки и результирующей таблицы (`VersionExportInfo`,
|
||||||
|
`_write_details`, `_group_name`, `_xlsx_response`) — без копий кода.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
- 22.10.2026. Требование автора перенесено из `openspec/specs/consolidations-api` в дельту `ADDED` отдельным
|
||||||
|
требованием со сценариями (у автора — абзац внутри требования «Результирующая таблица ВГО» без сценария); событие
|
||||||
|
аудита добавлено в `audit-trail`. Обоснование: владелец поручил закрыть замечания ревью силами ревьюера, прецедент —
|
||||||
|
change `intercompany-matrix`. Цена ошибки: низкая — текст правится в этом change'е до архивации.
|
||||||
|
- 22.10.2026. Аудит экспорта результирующей таблицы оформлен отдельным `ADDED`-требованием, а не `MODIFIED`
|
||||||
|
«Аудит экспорта шахматки». Обоснование: у событий разный payload (`articles` вместо режима и счетов), отдельное
|
||||||
|
требование не переписывает согласованный текст. Цена ошибки: низкая.
|
||||||
|
- 22.10.2026. Компания без суммы в статье при сборке книги даёт `KeyError` вместо молчаливого 0. Обоснование:
|
||||||
|
`calculate_result_table` выдаёт сумму на каждую компанию периметра, пропуск — ошибка расчёта, которую нельзя прятать
|
||||||
|
в файле. Цена ошибки: при таком дефекте расчёта выгрузка ответит 500 вместо файла с нулём.
|
||||||
|
- 22.10.2026. Общие реквизиты книги вынесены в базовый `VersionExportInfo`, затронут и код выгрузки шахматки.
|
||||||
|
Обоснование: замечание ревью о копиях шапки и имени файла. Цена ошибки: конфликт слияния с PR #141 (тот меняет
|
||||||
|
`MatrixExportInfo`); порядок — сначала #141, затем ребейз #159.
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Фронт шахматки (экран [31-01], результирующая таблица под матрицей) выгружает результирующую таблицу ВГО в xlsx —
|
||||||
|
запрос фронтенда к API. Реализация — PR RSHB/arm-core#159 (автор — Потапова). Требование автор дописала прямо в
|
||||||
|
`openspec/specs/`; при подготовке к слиянию оно оформлено дельтами этого change'а, как в change `intercompany-matrix`
|
||||||
|
(PR #114).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- `GET …/intercompany-matrix/result-table/export` — xlsx результирующей таблицы ВГО: те же статьи, компании и итоги,
|
||||||
|
что у `GET …/intercompany-matrix/result-table`, считается на лету из той же сводной ОСВ версии (§6).
|
||||||
|
- Каждая успешная выгрузка пишет `audit_event(EXPORT)` с `report = "intercompany_result_table"`.
|
||||||
|
- В OpenAPI у 422 маршрутов результирующей таблицы — своё описание («нет счетов статей»), а не «неверный выбор счетов».
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `consolidations-api`: xlsx результирующей таблицы ВГО.
|
||||||
|
- `audit-trail`: аудит экспорта результирующей таблицы.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Только `arm-core` (маршрут, сервис шахматки, построитель книги, тесты). Таблицы DBML v5.21 не меняются: действие
|
||||||
|
`EXPORT` и раздел `EXPORT` в `audit_action`/`audit_section` уже есть. Срез 5 (`09-delivery-plan.md`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Строка «Итого» по статьям: её нет ни в JSON, ни в xlsx (§6 описывает только колонку «Итого по группе»).
|
||||||
|
- Пересчёт в тыс. руб. и знаки после запятой: книга в рублях, как выгрузка шахматки.
|
||||||
|
- Кнопка выгрузки во фронте (`web/`) и строка в `../docs/dev/frontend-api-guide.md` — отдельными изменениями.
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Аудит экспорта результирующей таблицы ВГО
|
||||||
|
Каждый успешный `GET …/intercompany-matrix/result-table/export` SHALL добавлять отдельное неизменяемое событие
|
||||||
|
`audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`,
|
||||||
|
`object_id = consolidation_version_id`, `consolidation_version_id` версии, `user_id` инициатора, `before_value = null`
|
||||||
|
и `after_value = {report: "intercompany_result_table", articles}`, где `articles` — число статей в книге. Неуспешная
|
||||||
|
выгрузка (404, 422, ошибка сборки книги) события не пишет.
|
||||||
|
|
||||||
|
#### Scenario: Выгрузка таблицы
|
||||||
|
- **WHEN** пользователь выгружает результирующую таблицу версии
|
||||||
|
- **THEN** в `audit_event` одна запись `EXPORT` с `after_value = {report: "intercompany_result_table", articles: 5}`
|
||||||
|
|
||||||
|
#### Scenario: Нет счёта статьи
|
||||||
|
- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING`
|
||||||
|
- **THEN** событие `EXPORT` не записано
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: XLSX результирующей таблицы ВГО
|
||||||
|
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export` (`CONSOLIDATION_VIEW`,
|
||||||
|
policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что `GET …/intercompany-matrix/result-table`,
|
||||||
|
из того же расчёта сводной ОСВ версии. Книга — лист «Результирующая таблица» с шапкой (ГК, период, версия, единицы
|
||||||
|
«руб.») и таблицей «Статья ВГО» × компании периметра + колонка «Итого по группе»; суммы — числа с двумя знаками. Имя
|
||||||
|
файла — `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`. Ошибки — как у JSON-маршрута: невидимая или
|
||||||
|
несуществующая версия — 404, отсутствие необходимого кода счёта — 422 `RESULT_ACCOUNTS_MISSING`.
|
||||||
|
|
||||||
|
#### Scenario: Совпадение с JSON
|
||||||
|
- **WHEN** пользователь выгружает результирующую таблицу ГК «Русский бисквит» за 2024 Q1, версия 1
|
||||||
|
- **THEN** имя содержит `Русский бисквит_2024-Q1_v1.xlsx`, порядок компаний, названия статей, суммы по компаниям и
|
||||||
|
«Итого по группе» совпадают с ответом `GET …/result-table` той же версии
|
||||||
|
|
||||||
|
#### Scenario: Нет счёта статьи
|
||||||
|
- **WHEN** в плане счетов нет кода 58.03
|
||||||
|
- **THEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING` с кодом в сообщении, файл не формируется
|
||||||
|
|
||||||
|
#### Scenario: Версия вне видимости
|
||||||
|
- **WHEN** пользователь филиала выгружает таблицу версии чужой ГК
|
||||||
|
- **THEN** ответ 404 с тем же телом, что у несуществующей версии
|
||||||
10
openspec/changes/intercompany-result-table-export/tasks.md
Normal file
10
openspec/changes/intercompany-result-table-export/tasks.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
## 1. Реализация
|
||||||
|
|
||||||
|
- [x] 1.1 Маршрут `GET …/intercompany-matrix/result-table/export`, построитель книги, аудит `EXPORT` (автор, PR #159)
|
||||||
|
- [x] 1.2 Замечания ревью: `EXPECTED_ROUTES`, формат ruff, общие реквизиты книги, `KeyError` вместо 0, описание 422,
|
||||||
|
тесты 422 без события `EXPORT` и OpenAPI-схемы экспорта
|
||||||
|
|
||||||
|
## 2. После слияния
|
||||||
|
|
||||||
|
- [ ] 2.1 Ребейз на `main` после слияния PR #141 (`build_with_members` возвращает объект), `make check-full`
|
||||||
|
- [ ] 2.2 Кнопка выгрузки на экране результирующей таблицы (`web/`) и строка в `../docs/dev/frontend-api-guide.md`
|
||||||
@ -244,11 +244,6 @@ SHALL возвращать все правила версии массивом.
|
|||||||
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
из конечного сальдо нужной стороны и выбранных счетов статьи; `group_total` — сумма по компаниям.
|
||||||
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
Отсутствие любого необходимого кода счёта SHALL давать 422 `RESULT_ACCOUNTS_MISSING` с перечнем кодов.
|
||||||
|
|
||||||
`GET /api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export`
|
|
||||||
(`CONSOLIDATION_VIEW`, policy) SHALL возвращать XLSX с теми же статьями, компаниями и итогами, что JSON-таблица.
|
|
||||||
Имя файла: `intercompany-result-table_<ГК>_<год>-<квартал>_v<n>.xlsx`.
|
|
||||||
Успешная выгрузка SHALL добавлять событие `EXPORT` с `report = "intercompany_result_table"`.
|
|
||||||
|
|
||||||
#### Scenario: Компания без ОСВ
|
#### Scenario: Компания без ОСВ
|
||||||
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
- **WHEN** в периметре есть компания без подходящих строк ОСВ
|
||||||
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
|
- **THEN** она есть в `companies`, её сумма по каждой статье — строка `"0"`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user