Правки

This commit is contained in:
Raykov-MS 2026-05-25 18:46:11 +03:00
parent ad6eae9c0b
commit fc55a7b042
7 changed files with 216 additions and 0 deletions

View File

@ -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)},
)

View File

@ -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"],
}

View File

@ -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

View File

@ -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)

View File

@ -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]

View 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

View File

@ -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