Правки
This commit is contained in:
parent
ad6eae9c0b
commit
fc55a7b042
@ -9,6 +9,7 @@ from src.domain.schemas import DirectionSchemaEnum, ExportBulkRequest
|
|||||||
from src.services.export_service import ExportService
|
from src.services.export_service import ExportService
|
||||||
|
|
||||||
router = APIRouter(prefix="/export", tags=["export"])
|
router = APIRouter(prefix="/export", tags=["export"])
|
||||||
|
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
|
||||||
|
|
||||||
|
|
||||||
def _parse_sections_csv(sections: str | None) -> list[str] | None:
|
def _parse_sections_csv(sections: str | None) -> list[str] | None:
|
||||||
@ -36,6 +37,16 @@ def _parse_sections_csv(sections: str | None) -> list[str] | None:
|
|||||||
return normalized or None
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_form3_sections_csv(sections: str | None) -> list[str] | None:
|
||||||
|
if not sections:
|
||||||
|
return None
|
||||||
|
parsed = [section.strip() for section in sections.split(",") if section.strip()]
|
||||||
|
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@router.get("/form/{form_id}")
|
@router.get("/form/{form_id}")
|
||||||
async def export_form(
|
async def export_form(
|
||||||
form_id: int,
|
form_id: int,
|
||||||
@ -115,3 +126,30 @@ async def export_forms_bulk(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/project/{project_id}/report/{year}/{report_type}")
|
||||||
|
async def export_project_report(
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
sections: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(get_current_active_user_with_set_db),
|
||||||
|
):
|
||||||
|
export_service = ExportService(db)
|
||||||
|
try:
|
||||||
|
stream, filename, media_type = await export_service.export_project_report_payload(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
current_user=current_user,
|
||||||
|
sections=_parse_form3_sections_csv(sections),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type=media_type,
|
||||||
|
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -34,11 +34,13 @@ class FormType(Base):
|
|||||||
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
|
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
|
||||||
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
|
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
|
||||||
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
|
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
|
||||||
|
FormTypeEnum.FORM_3: ["LIMIT", "CURRENT_EXPENSES"],
|
||||||
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
|
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
|
||||||
}
|
}
|
||||||
SECTIONS_BY_FORM_TYPE = {
|
SECTIONS_BY_FORM_TYPE = {
|
||||||
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
|
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
|
||||||
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
|
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
|
||||||
|
FormTypeEnum.FORM_3: ["q1", "q2", "q3", "q4", "year"],
|
||||||
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
|
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -209,3 +209,40 @@ class SmetaRowNormalizer:
|
|||||||
return None
|
return None
|
||||||
cur = cur.get(key)
|
cur = cur.get(key)
|
||||||
return cur
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
class Form3ReportRowNormalizer:
|
||||||
|
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized = dict(data)
|
||||||
|
header = data.get("header")
|
||||||
|
if isinstance(header, dict):
|
||||||
|
if "section_code" in header and "section" not in normalized:
|
||||||
|
normalized["section"] = header.get("section_code")
|
||||||
|
if "item_id" in header and "item_id" not in normalized:
|
||||||
|
normalized["item_id"] = header.get("item_id")
|
||||||
|
if "name" in header and "name" not in normalized:
|
||||||
|
normalized["name"] = header.get("name")
|
||||||
|
for quarter_key in ("q1", "q2", "q3", "q4"):
|
||||||
|
normalized[quarter_key] = self._extract_quarter_metric(normalized.get(quarter_key))
|
||||||
|
normalized["totals"] = self._extract_totals_metric(normalized.get("totals"))
|
||||||
|
normalized.pop("header", None)
|
||||||
|
normalized.pop("line_id", None)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_quarter_metric(value: Any) -> Any:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
for key in ("quarter_actual", "total_corr", "economy"):
|
||||||
|
if value.get(key) is not None:
|
||||||
|
return value.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_totals_metric(value: Any) -> Any:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
for key in ("total_actual", "total_corr", "economy"):
|
||||||
|
if value.get(key) is not None:
|
||||||
|
return value.get(key)
|
||||||
|
return None
|
||||||
|
|||||||
@ -18,13 +18,16 @@ from src.services.budget_form_service import BudgetFormService
|
|||||||
from src.services.export_normalizers import (
|
from src.services.export_normalizers import (
|
||||||
ExcelValueSerializer,
|
ExcelValueSerializer,
|
||||||
Form1RowNormalizer,
|
Form1RowNormalizer,
|
||||||
|
Form3ReportRowNormalizer,
|
||||||
SmetaRowNormalizer,
|
SmetaRowNormalizer,
|
||||||
)
|
)
|
||||||
from src.services.export_writers import (
|
from src.services.export_writers import (
|
||||||
FORM1_FLAT_COLUMNS,
|
FORM1_FLAT_COLUMNS,
|
||||||
|
FORM3_REPORT_COLUMNS,
|
||||||
SMETA_COLUMNS,
|
SMETA_COLUMNS,
|
||||||
TabularSheetWriter,
|
TabularSheetWriter,
|
||||||
)
|
)
|
||||||
|
from src.services.project_service import ProjectService
|
||||||
from src.services.sheet_service import SheetService
|
from src.services.sheet_service import SheetService
|
||||||
|
|
||||||
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
@ -46,8 +49,10 @@ class ExportService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.budget_form_service = BudgetFormService(db)
|
self.budget_form_service = BudgetFormService(db)
|
||||||
self.sheet_service = SheetService(db)
|
self.sheet_service = SheetService(db)
|
||||||
|
self.project_service = ProjectService(db)
|
||||||
self.value_serializer = ExcelValueSerializer()
|
self.value_serializer = ExcelValueSerializer()
|
||||||
self.form1_row_normalizer = Form1RowNormalizer(self.value_serializer)
|
self.form1_row_normalizer = Form1RowNormalizer(self.value_serializer)
|
||||||
|
self.form3_report_row_normalizer = Form3ReportRowNormalizer()
|
||||||
self.smeta_row_normalizer = SmetaRowNormalizer()
|
self.smeta_row_normalizer = SmetaRowNormalizer()
|
||||||
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
||||||
"__default__": TabularSheetWriter(
|
"__default__": TabularSheetWriter(
|
||||||
@ -61,6 +66,11 @@ class ExportService:
|
|||||||
value_serializer=self.value_serializer.to_excel_value,
|
value_serializer=self.value_serializer.to_excel_value,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
self.form3_report_writer = TabularSheetWriter(
|
||||||
|
columns=FORM3_REPORT_COLUMNS,
|
||||||
|
row_normalizer=self.form3_report_row_normalizer.normalize,
|
||||||
|
value_serializer=self.value_serializer.to_excel_value,
|
||||||
|
)
|
||||||
|
|
||||||
async def export_form_payload(
|
async def export_form_payload(
|
||||||
self,
|
self,
|
||||||
@ -81,6 +91,23 @@ class ExportService:
|
|||||||
)
|
)
|
||||||
return stream, filename, XLSX_MEDIA_TYPE
|
return stream, filename, XLSX_MEDIA_TYPE
|
||||||
|
|
||||||
|
async def export_project_report_payload(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
current_user: AppUser,
|
||||||
|
sections: list[str] | None = None,
|
||||||
|
) -> tuple[io.BytesIO, str, str]:
|
||||||
|
stream, filename = await self.export_project_report_to_xlsx(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
current_user=current_user,
|
||||||
|
sections=sections,
|
||||||
|
)
|
||||||
|
return stream, filename, XLSX_MEDIA_TYPE
|
||||||
|
|
||||||
async def export_bulk_payload(
|
async def export_bulk_payload(
|
||||||
self,
|
self,
|
||||||
form_ids: list[int],
|
form_ids: list[int],
|
||||||
@ -183,6 +210,46 @@ class ExportService:
|
|||||||
filename = self._build_filename(form_id=form.id, form_type_code=form.form_type.code, year=form.year, sheet=sheet)
|
filename = self._build_filename(form_id=form.id, form_type_code=form.form_type.code, year=form.year, sheet=sheet)
|
||||||
return stream, filename
|
return stream, filename
|
||||||
|
|
||||||
|
async def export_project_report_to_xlsx(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
report_type: str,
|
||||||
|
current_user: AppUser,
|
||||||
|
sections: list[str] | None = None,
|
||||||
|
) -> tuple[io.BytesIO, str]:
|
||||||
|
rows = await self.project_service.get_report_rows(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
report_type=report_type,
|
||||||
|
sections=sections,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
report_type_upper = report_type.upper()
|
||||||
|
workbook = Workbook()
|
||||||
|
meta_sheet = workbook.active
|
||||||
|
meta_sheet.title = "metadata"
|
||||||
|
metadata_rows = [
|
||||||
|
("Тип формы", "FORM_3"),
|
||||||
|
("Project ID", project_id),
|
||||||
|
("Year", year),
|
||||||
|
("Report Type", report_type_upper),
|
||||||
|
("Sections", ", ".join(sections) if sections else None),
|
||||||
|
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
|
||||||
|
]
|
||||||
|
for row_idx, (key, value) in enumerate(metadata_rows, start=1):
|
||||||
|
meta_sheet.cell(row=row_idx, column=1, value=key)
|
||||||
|
meta_sheet.cell(row=row_idx, column=2, value=value)
|
||||||
|
worksheet = workbook.create_sheet(title=self._safe_sheet_name(report_type_upper))
|
||||||
|
self.form3_report_writer.write(worksheet, rows)
|
||||||
|
stream = io.BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
workbook.close()
|
||||||
|
stream.seek(0)
|
||||||
|
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
filename = f"{year}_FORM_3_{project_id}_{report_type_upper}_{date_str}.xlsx"
|
||||||
|
return stream, filename
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_content_disposition(filename: str) -> str:
|
def build_content_disposition(filename: str) -> str:
|
||||||
encoded = quote(filename)
|
encoded = quote(filename)
|
||||||
|
|||||||
@ -67,6 +67,16 @@ SMETA_COLUMNS: list[tuple[str, str]] = [
|
|||||||
("corrected_development_q3", "Корректировка развитие III квартал"),
|
("corrected_development_q3", "Корректировка развитие III квартал"),
|
||||||
("corrected_development_q4", "Корректировка развитие IV квартал"),
|
("corrected_development_q4", "Корректировка развитие IV квартал"),
|
||||||
]
|
]
|
||||||
|
FORM3_REPORT_COLUMNS: list[tuple[str, str]] = [
|
||||||
|
("section", "Код раздела"),
|
||||||
|
("item_id", "ID статьи"),
|
||||||
|
("name", "Наименование"),
|
||||||
|
("q1", "I квартал"),
|
||||||
|
("q2", "II квартал"),
|
||||||
|
("q3", "III квартал"),
|
||||||
|
("q4", "IV квартал"),
|
||||||
|
("totals", "ИТОГО"),
|
||||||
|
]
|
||||||
|
|
||||||
RowNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
|
RowNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
|
||||||
ValueSerializer = Callable[[Any], Any]
|
ValueSerializer = Callable[[Any], Any]
|
||||||
|
|||||||
38
api/tests/integration/test_export_projects_integration.py
Normal file
38
api/tests/integration/test_export_projects_integration.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _get_any_project_report(client, admin_tokens, auth_headers) -> tuple[int, int, str]:
|
||||||
|
list_response = client.get(
|
||||||
|
"/api/v1/projects?limit=50",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
projects = list_response.json().get("result") or []
|
||||||
|
for project in projects:
|
||||||
|
project_id = int(project["id"])
|
||||||
|
details_response = client.get(
|
||||||
|
f"/api/v1/projects/{project_id}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
if details_response.status_code != 200:
|
||||||
|
continue
|
||||||
|
reports = (details_response.json().get("result") or {}).get("reports") or []
|
||||||
|
if reports:
|
||||||
|
report = reports[0]
|
||||||
|
return project_id, int(report["year"]), str(report["report_type"])
|
||||||
|
pytest.skip("Не найден проект FORM_3 с отчетами для smoke-экспорта")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_project_report_smoke(client, admin_tokens, auth_headers):
|
||||||
|
project_id, report_year, report_type = _get_any_project_report(client, admin_tokens, auth_headers)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/export/project/{project_id}/report/{report_year}/{report_type}",
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == (
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
|
assert "attachment; filename*=UTF-8''" in response.headers["content-disposition"]
|
||||||
|
assert len(response.content) > 0
|
||||||
@ -1,6 +1,7 @@
|
|||||||
from src.services.export_normalizers import (
|
from src.services.export_normalizers import (
|
||||||
ExcelValueSerializer,
|
ExcelValueSerializer,
|
||||||
Form1RowNormalizer,
|
Form1RowNormalizer,
|
||||||
|
Form3ReportRowNormalizer,
|
||||||
SmetaRowNormalizer,
|
SmetaRowNormalizer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -49,3 +50,26 @@ def test_excel_value_serializer_formats_collections():
|
|||||||
assert serializer.to_excel_value(["a", 1, None]) == "a, 1, "
|
assert serializer.to_excel_value(["a", 1, None]) == "a, 1, "
|
||||||
assert serializer.to_excel_value({"name": "X"}) == "X"
|
assert serializer.to_excel_value({"name": "X"}) == "X"
|
||||||
assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1"
|
assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_normalizer_flattens_quarters_and_totals_to_scalars():
|
||||||
|
normalizer = Form3ReportRowNormalizer()
|
||||||
|
row = {
|
||||||
|
"header": {"section_code": "1.00.0.", "item_id": "R001", "name": "Строка"},
|
||||||
|
"q1": {"quarter_actual": 10, "total_corr": 12},
|
||||||
|
"q2": {"total_corr": 22},
|
||||||
|
"q3": {"economy": 1.5},
|
||||||
|
"q4": {"m1": 100},
|
||||||
|
"totals": {"total_actual": 40, "economy": 2.1},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalizer.normalize(row)
|
||||||
|
|
||||||
|
assert normalized["section"] == "1.00.0."
|
||||||
|
assert normalized["item_id"] == "R001"
|
||||||
|
assert normalized["name"] == "Строка"
|
||||||
|
assert normalized["q1"] == 10
|
||||||
|
assert normalized["q2"] == 22
|
||||||
|
assert normalized["q3"] == 1.5
|
||||||
|
assert normalized["q4"] is None
|
||||||
|
assert normalized["totals"] == 40
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user