добавлено выгрузка в xlsx для таблицы ВГО
This commit is contained in:
parent
57f8032dbe
commit
ea51cb35c2
@ -14,7 +14,7 @@ from src.models.enums import AuditAction, AuditSection
|
|||||||
from src.schemas.common import ErrorDetail
|
from src.schemas.common import ErrorDetail
|
||||||
from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut
|
from src.schemas.consolidations import IntercompanyMatrixManualIn, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||||
from src.services.intercompany_matrix import MatrixExportInfo
|
from src.services.intercompany_matrix import MatrixExportInfo
|
||||||
from src.services.intercompany_matrix_export import build_matrix_workbook
|
from src.services.intercompany_matrix_export import build_matrix_workbook, build_result_table_workbook
|
||||||
|
|
||||||
router = APIRouter(prefix="/consolidations/versions")
|
router = APIRouter(prefix="/consolidations/versions")
|
||||||
|
|
||||||
@ -158,3 +158,35 @@ async def get_intercompany_result_table(
|
|||||||
) -> IntercompanyResultTableOut:
|
) -> IntercompanyResultTableOut:
|
||||||
"""Пять фиксированных статей: суммы по компаниям и итоги по группе."""
|
"""Пять фиксированных статей: суммы по компаниям и итоги по группе."""
|
||||||
return await service.build_result_table(version_id)
|
return await service.build_result_table(version_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{version_id}/intercompany-matrix/result-table/export",
|
||||||
|
response_class=Response,
|
||||||
|
dependencies=VIEW,
|
||||||
|
responses=EXPORT_RESPONSES,
|
||||||
|
summary="Скачать результирующую таблицу ВГО в XLSX",
|
||||||
|
)
|
||||||
|
async def export_intercompany_result_table(
|
||||||
|
version_id: uuid.UUID,
|
||||||
|
service: IntercompanyMatrixServiceDep,
|
||||||
|
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||||
|
) -> Response:
|
||||||
|
result, info = await service.build_result_export(version_id)
|
||||||
|
content = await run_in_threadpool(build_result_table_workbook, result, info)
|
||||||
|
response = Response(
|
||||||
|
content=content,
|
||||||
|
media_type=XLSX_MEDIA_TYPE,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": content_disposition(info.filename, ascii_fallback="intercompany-result-table.xlsx")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await audit.record(
|
||||||
|
action=AuditAction.EXPORT,
|
||||||
|
section=AuditSection.EXPORT,
|
||||||
|
object_type="consolidation_version",
|
||||||
|
object_id=version_id,
|
||||||
|
consolidation_version_id=version_id,
|
||||||
|
after_value={"report": "intercompany_result_table", "articles": len(result.articles)},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|||||||
@ -90,6 +90,25 @@ class MatrixExportInfo:
|
|||||||
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultTableExportInfo:
|
||||||
|
group_name: str
|
||||||
|
year: int
|
||||||
|
quarter: PeriodQuarter
|
||||||
|
version_no: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self) -> str:
|
||||||
|
return f"intercompany-result-table_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultTableBuild:
|
||||||
|
table: IntercompanyResultTableOut
|
||||||
|
version: ConsolidationVersion
|
||||||
|
consolidation: Consolidation
|
||||||
|
|
||||||
|
|
||||||
def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
def _amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal:
|
||||||
if measure == "closing":
|
if measure == "closing":
|
||||||
value = row.closing_debit if side == "D" else row.closing_credit
|
value = row.closing_debit if side == "D" else row.closing_credit
|
||||||
@ -300,10 +319,31 @@ class IntercompanyMatrixService:
|
|||||||
|
|
||||||
async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut:
|
async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut:
|
||||||
"""Статичная таблица по пяти статьям"""
|
"""Статичная таблица по пяти статьям"""
|
||||||
consolidated, _, _, members = await self._osv_service.build_with_members(version_id)
|
return (await self._build_result_table(version_id)).table
|
||||||
|
|
||||||
|
async def build_result_export(
|
||||||
|
self, version_id: uuid.UUID
|
||||||
|
) -> tuple[IntercompanyResultTableOut, ResultTableExportInfo]:
|
||||||
|
"""Таблица и реквизиты книги из одного расчёта свода."""
|
||||||
|
built = await self._build_result_table(version_id)
|
||||||
|
group_name = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(CompanyGroup.name).where(CompanyGroup.id == built.consolidation.company_group_id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
info = ResultTableExportInfo(
|
||||||
|
group_name, built.consolidation.year, built.consolidation.quarter, built.version.version_no
|
||||||
|
)
|
||||||
|
return built.table, info
|
||||||
|
|
||||||
|
async def _build_result_table(
|
||||||
|
self, version_id: uuid.UUID
|
||||||
|
) -> ResultTableBuild:
|
||||||
|
consolidated, version, consolidation, members = await self._osv_service.build_with_members(version_id)
|
||||||
accounts = await directory_repo.list_accounts(self._session)
|
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()]
|
||||||
return calculate_result_table(consolidated.rows, companies, accounts)
|
result = calculate_result_table(consolidated.rows, companies, accounts)
|
||||||
|
return ResultTableBuild(result, version, consolidation)
|
||||||
|
|
||||||
async def build_accounts(
|
async def build_accounts(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@ -7,8 +7,8 @@ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|||||||
from openpyxl.utils import get_column_letter
|
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
|
from src.schemas.consolidations import IntercompanyMatrixCellOut, IntercompanyMatrixOut, IntercompanyResultTableOut
|
||||||
from src.services.intercompany_matrix import MatrixExportInfo
|
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo
|
||||||
from src.services.osv_export import MONEY_FORMAT
|
from src.services.osv_export import MONEY_FORMAT
|
||||||
|
|
||||||
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
|
METRICS = ("Разница", "Дебет", "Кредит", "Расхождение, %")
|
||||||
@ -115,3 +115,50 @@ def build_matrix_workbook(matrix: IntercompanyMatrixOut, info: MatrixExportInfo)
|
|||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
workbook.save(buffer)
|
workbook.save(buffer)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_result_table_workbook(result: IntercompanyResultTableOut, info: ResultTableExportInfo) -> bytes:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
assert sheet is not None
|
||||||
|
sheet.title = "Результирующая таблица"
|
||||||
|
_text(sheet, 1, 1, "Результирующая таблица ВГО")
|
||||||
|
for row, label, value in (
|
||||||
|
(2, "ГК", info.group_name),
|
||||||
|
(3, "Период", f"{info.year}-{info.quarter.value}"),
|
||||||
|
(4, "Версия", f"v{info.version_no}"),
|
||||||
|
(5, "Единицы", "руб."),
|
||||||
|
):
|
||||||
|
_text(sheet, row, 1, label)
|
||||||
|
_text(sheet, row, 2, value)
|
||||||
|
sheet.cell(row, 1).font = Font(bold=True)
|
||||||
|
|
||||||
|
header_row = 7
|
||||||
|
_text(sheet, header_row, 1, "Статья ВГО")
|
||||||
|
sheet.column_dimensions["A"].width = 42
|
||||||
|
for column, company in enumerate(result.companies, start=2):
|
||||||
|
_text(sheet, header_row, column, company.name)
|
||||||
|
sheet.column_dimensions[get_column_letter(column)].width = 26
|
||||||
|
total_column = len(result.companies) + 2
|
||||||
|
_text(sheet, header_row, total_column, "Итого по группе")
|
||||||
|
sheet.column_dimensions[get_column_letter(total_column)].width = 26
|
||||||
|
sheet.row_dimensions[header_row].height = 36
|
||||||
|
for column in range(1, total_column + 1):
|
||||||
|
cell = sheet.cell(header_row, column)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = HEADER_FILL
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for row, article in enumerate(result.articles, start=header_row + 1):
|
||||||
|
_text(sheet, row, 1, article.name)
|
||||||
|
amounts = {amount.company_id: amount.amount for amount in article.amounts}
|
||||||
|
for column, company in enumerate(result.companies, start=2):
|
||||||
|
sheet.cell(row, column, amounts.get(company.id, 0)).number_format = MONEY_FORMAT
|
||||||
|
total = sheet.cell(row, total_column, article.group_total)
|
||||||
|
total.number_format = MONEY_FORMAT
|
||||||
|
total.font = Font(bold=True)
|
||||||
|
|
||||||
|
sheet.freeze_panes = "B8"
|
||||||
|
buffer = BytesIO()
|
||||||
|
workbook.save(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|||||||
@ -27,7 +27,7 @@ from tests.factories import make_company_group, make_confirmed_osv, make_consoli
|
|||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
|
OK, FORBIDDEN, NOT_FOUND, UNPROCESSABLE = 200, 403, 404, 422
|
||||||
KA_RF = "ka_rf@armka.local"
|
KA_RF = "ka_rf@armka.local"
|
||||||
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export")
|
ROUTES = ("all", "manual", "result-table", "all/export", "manual/export", "result-table/export")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -231,6 +231,28 @@ async def test_result_accounts_missing(auth_client: AuthClientFactory, seeded: A
|
|||||||
assert "58.03" in response.json()["detail"]["message"]
|
assert "58.03" in response.json()["detail"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_table_export_matches_json(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||||
|
world = await _world(seeded)
|
||||||
|
await _source(seeded, world, credit=False, amount="100.25")
|
||||||
|
client = await auth_client(KA_RF)
|
||||||
|
result = await _request(client, world.version.id, "result-table", world.selection)
|
||||||
|
export = await _request(client, world.version.id, "result-table/export", world.selection)
|
||||||
|
assert result.status_code == export.status_code == OK
|
||||||
|
data = result.json()
|
||||||
|
sheet = load_workbook(BytesIO(export.content))["Результирующая таблица"]
|
||||||
|
assert [sheet.cell(7, col).value for col in range(2, len(data["companies"]) + 2)] == [
|
||||||
|
company["name"] for company in data["companies"]
|
||||||
|
]
|
||||||
|
for row, article in enumerate(data["articles"], start=8):
|
||||||
|
assert sheet.cell(row, 1).value == article["name"]
|
||||||
|
amounts = {item["company_id"]: Decimal(item["amount"]) for item in article["amounts"]}
|
||||||
|
for col, company in enumerate(data["companies"], start=2):
|
||||||
|
assert Decimal(str(sheet.cell(row, col).value)) == amounts[company["id"]]
|
||||||
|
assert Decimal(str(sheet.cell(row, len(data["companies"]) + 2).value)) == Decimal(article["group_total"])
|
||||||
|
event = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
|
||||||
|
assert event.after_value == {"report": "intercompany_result_table", "articles": len(data["articles"])}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("mode", ["all", "manual"])
|
@pytest.mark.parametrize("mode", ["all", "manual"])
|
||||||
async def test_export_filename_header_and_percentage(
|
async def test_export_filename_header_and_percentage(
|
||||||
auth_client: AuthClientFactory, seeded: AsyncSession, mode: str
|
auth_client: AuthClientFactory, seeded: AsyncSession, mode: str
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from openpyxl import load_workbook
|
|||||||
from src.api.endpoints.consolidations.intercompany_matrix import (
|
from src.api.endpoints.consolidations.intercompany_matrix import (
|
||||||
export_intercompany_matrix_all,
|
export_intercompany_matrix_all,
|
||||||
export_intercompany_matrix_manual,
|
export_intercompany_matrix_manual,
|
||||||
|
export_intercompany_result_table,
|
||||||
router,
|
router,
|
||||||
)
|
)
|
||||||
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
|
from src.models.enums import AuditAction, AuditSection, PeriodQuarter
|
||||||
@ -20,9 +21,17 @@ from src.schemas.consolidations import (
|
|||||||
IntercompanyMatrixCompanyOut,
|
IntercompanyMatrixCompanyOut,
|
||||||
IntercompanyMatrixManualIn,
|
IntercompanyMatrixManualIn,
|
||||||
IntercompanyMatrixOut,
|
IntercompanyMatrixOut,
|
||||||
|
IntercompanyResultAmountOut,
|
||||||
|
IntercompanyResultArticleOut,
|
||||||
|
IntercompanyResultTableOut,
|
||||||
|
)
|
||||||
|
from src.services.intercompany_matrix import MatrixExportInfo, ResultTableExportInfo
|
||||||
|
from src.services.intercompany_matrix_export import (
|
||||||
|
DIAGONAL_FILL,
|
||||||
|
METRICS,
|
||||||
|
THRESHOLD_FILL,
|
||||||
|
build_matrix_workbook,
|
||||||
)
|
)
|
||||||
from src.services.intercompany_matrix import MatrixExportInfo
|
|
||||||
from src.services.intercompany_matrix_export import DIAGONAL_FILL, METRICS, THRESHOLD_FILL, build_matrix_workbook
|
|
||||||
|
|
||||||
|
|
||||||
def _info(*, mode: str = "MANUAL") -> MatrixExportInfo:
|
def _info(*, mode: str = "MANUAL") -> MatrixExportInfo:
|
||||||
@ -229,3 +238,74 @@ async def test_failed_matrix_export_does_not_write_audit(monkeypatch: pytest.Mon
|
|||||||
with pytest.raises(ValueError, match="xlsx failed"):
|
with pytest.raises(ValueError, match="xlsx failed"):
|
||||||
await export_intercompany_matrix_all(version_id, service, audit)
|
await export_intercompany_matrix_all(version_id, service, audit)
|
||||||
audit.record.assert_not_awaited()
|
audit.record.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_result_table_export_matches_result_and_records_audit() -> None:
|
||||||
|
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||||
|
other_id = UUID("22222222-2222-2222-2222-222222222222")
|
||||||
|
result = IntercompanyResultTableOut(
|
||||||
|
companies=[
|
||||||
|
IntercompanyMatrixCompanyOut(id=version_id, name="=Компания А"),
|
||||||
|
IntercompanyMatrixCompanyOut(id=other_id, name="Компания Б"),
|
||||||
|
],
|
||||||
|
articles=[
|
||||||
|
IntercompanyResultArticleOut(
|
||||||
|
code="receivables",
|
||||||
|
name="Дебиторская задолженность",
|
||||||
|
amounts=[
|
||||||
|
IntercompanyResultAmountOut(company_id=version_id, amount="100.25"),
|
||||||
|
IntercompanyResultAmountOut(company_id=other_id, amount="20.00"),
|
||||||
|
],
|
||||||
|
group_total="120.25",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
info = ResultTableExportInfo('ГК "Кавычки" / Бисквит', 2024, PeriodQuarter.Q3, 2)
|
||||||
|
service = SimpleNamespace(build_result_export=AsyncMock(return_value=(result, info)))
|
||||||
|
audit = SimpleNamespace(record=AsyncMock())
|
||||||
|
|
||||||
|
response = await export_intercompany_result_table(version_id, service, audit)
|
||||||
|
service.build_result_export.assert_awaited_once_with(version_id)
|
||||||
|
assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
assert unquote(response.headers["content-disposition"].split("filename*=UTF-8''", 1)[1]) == info.filename
|
||||||
|
sheet = load_workbook(BytesIO(response.body))["Результирующая таблица"]
|
||||||
|
assert [sheet[f"B{row}"].value for row in range(2, 6)] == [info.group_name, "2024-Q3", "v2", "руб."]
|
||||||
|
assert [sheet.cell(7, column).value for column in range(1, 5)] == [
|
||||||
|
"Статья ВГО", "=Компания А", "Компания Б", "Итого по группе"
|
||||||
|
]
|
||||||
|
assert sheet["B7"].data_type == "s"
|
||||||
|
assert [sheet.cell(8, column).value for column in range(1, 5)] == [
|
||||||
|
"Дебиторская задолженность", 100.25, 20, 120.25
|
||||||
|
]
|
||||||
|
assert all(sheet.cell(8, column).number_format == '#,##0.00' for column in range(2, 5))
|
||||||
|
assert sheet.freeze_panes == "B8"
|
||||||
|
assert audit.record.await_args.kwargs == {
|
||||||
|
"action": AuditAction.EXPORT,
|
||||||
|
"section": AuditSection.EXPORT,
|
||||||
|
"object_type": "consolidation_version",
|
||||||
|
"object_id": version_id,
|
||||||
|
"consolidation_version_id": version_id,
|
||||||
|
"after_value": {"report": "intercompany_result_table", "articles": 1},
|
||||||
|
}
|
||||||
|
assert ("/consolidations/versions/{version_id}/intercompany-matrix/result-table/export", ("GET",)) in {
|
||||||
|
(route.path, tuple(sorted(route.methods))) for route in router.routes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failed_result_table_export_does_not_write_audit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
version_id = UUID("11111111-1111-1111-1111-111111111111")
|
||||||
|
info = ResultTableExportInfo("ГК", 2024, PeriodQuarter.Q3, 2)
|
||||||
|
service = SimpleNamespace(
|
||||||
|
build_result_export=AsyncMock(return_value=(IntercompanyResultTableOut(companies=[], articles=[]), info))
|
||||||
|
)
|
||||||
|
audit = SimpleNamespace(record=AsyncMock())
|
||||||
|
|
||||||
|
def fail_workbook(_result: IntercompanyResultTableOut, _info: ResultTableExportInfo) -> bytes:
|
||||||
|
raise ValueError("xlsx failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.api.endpoints.consolidations.intercompany_matrix.build_result_table_workbook", fail_workbook
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="xlsx failed"):
|
||||||
|
await export_intercompany_result_table(version_id, service, audit)
|
||||||
|
audit.record.assert_not_awaited()
|
||||||
|
|||||||
@ -244,6 +244,11 @@ 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