diff --git a/api/src/api/v1/export.py b/api/src/api/v1/export.py index 5b9c515..b6d9c1b 100644 --- a/api/src/api/v1/export.py +++ b/api/src/api/v1/export.py @@ -9,6 +9,7 @@ from src.domain.schemas import DirectionSchemaEnum, ExportBulkRequest from src.services.export_service import ExportService router = APIRouter(prefix="/export", tags=["export"]) +FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"} 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 +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}") async def export_form( 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)}, + ) + + diff --git a/api/src/db/models/form_type.py b/api/src/db/models/form_type.py index 9194e9a..c5ede4b 100644 --- a/api/src/db/models/form_type.py +++ b/api/src/db/models/form_type.py @@ -34,11 +34,13 @@ class FormType(Base): FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"], FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT", "AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"], + FormTypeEnum.FORM_3: ["LIMIT", "CURRENT_EXPENSES"], FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"], } 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_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"], } diff --git a/api/src/services/export_normalizers.py b/api/src/services/export_normalizers.py index 6962598..c8fe28d 100644 --- a/api/src/services/export_normalizers.py +++ b/api/src/services/export_normalizers.py @@ -209,3 +209,40 @@ class SmetaRowNormalizer: return None cur = cur.get(key) 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 diff --git a/api/src/services/export_service.py b/api/src/services/export_service.py index fc5cb8d..e6dd1e9 100644 --- a/api/src/services/export_service.py +++ b/api/src/services/export_service.py @@ -18,13 +18,16 @@ from src.services.budget_form_service import BudgetFormService from src.services.export_normalizers import ( ExcelValueSerializer, Form1RowNormalizer, + Form3ReportRowNormalizer, SmetaRowNormalizer, ) from src.services.export_writers import ( FORM1_FLAT_COLUMNS, + FORM3_REPORT_COLUMNS, SMETA_COLUMNS, TabularSheetWriter, ) +from src.services.project_service import ProjectService from src.services.sheet_service import SheetService XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @@ -46,8 +49,10 @@ class ExportService: def __init__(self, db: AsyncSession): self.budget_form_service = BudgetFormService(db) self.sheet_service = SheetService(db) + self.project_service = ProjectService(db) self.value_serializer = ExcelValueSerializer() self.form1_row_normalizer = Form1RowNormalizer(self.value_serializer) + self.form3_report_row_normalizer = Form3ReportRowNormalizer() self.smeta_row_normalizer = SmetaRowNormalizer() self.sheet_writers: dict[str, TabularSheetWriter] = { "__default__": TabularSheetWriter( @@ -61,6 +66,11 @@ class ExportService: 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( self, @@ -81,6 +91,23 @@ class ExportService: ) 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( self, 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) 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 def build_content_disposition(filename: str) -> str: encoded = quote(filename) diff --git a/api/src/services/export_writers.py b/api/src/services/export_writers.py index 1c8c43e..fb70fdf 100644 --- a/api/src/services/export_writers.py +++ b/api/src/services/export_writers.py @@ -67,6 +67,16 @@ SMETA_COLUMNS: list[tuple[str, str]] = [ ("corrected_development_q3", "Корректировка развитие III квартал"), ("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]] ValueSerializer = Callable[[Any], Any] diff --git a/api/tests/integration/test_export_projects_integration.py b/api/tests/integration/test_export_projects_integration.py new file mode 100644 index 0000000..77fa3c6 --- /dev/null +++ b/api/tests/integration/test_export_projects_integration.py @@ -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 diff --git a/api/tests/unit/test_export_normalizers.py b/api/tests/unit/test_export_normalizers.py index 72b9c17..933aaf6 100644 --- a/api/tests/unit/test_export_normalizers.py +++ b/api/tests/unit/test_export_normalizers.py @@ -1,6 +1,7 @@ from src.services.export_normalizers import ( ExcelValueSerializer, Form1RowNormalizer, + Form3ReportRowNormalizer, 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({"name": "X"}) == "X" 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