Merge pull request 'project-back-fix: экпорт проектов, нельзя ставить отрицательные числа в полях проектов, создание года только на следующий год' (#111) from project-back-fix into test

Reviewed-on: #111
This commit is contained in:
tsygankoviva 2026-08-21 09:12:15 +03:00
commit 5d0b1aba9c
17 changed files with 741 additions and 112 deletions

View File

@ -0,0 +1,53 @@
from alembic import op
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Приводим перечень project_type к значениям из UI (projectConfig.jsx / ProjectTypeLiteral)
op.execute("ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_project_type_check")
op.execute(
"""
ALTER TABLE v3.project ADD CONSTRAINT project_project_type_check
CHECK (project_type IS NULL OR project_type IN (
'Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ'
))
"""
)
# Обязательные поля проекта (как во фронтенде): заполняем NULL-ы дефолтами,
# затем накладываем NOT NULL
op.execute("UPDATE v3.project SET name = 'Проект' WHERE name IS NULL OR name = ''")
op.execute("UPDATE v3.project SET project_type = 'Открытие ВСП' WHERE project_type IS NULL")
op.execute("UPDATE v3.project SET vsp_format = 'Типовой' WHERE vsp_format IS NULL")
op.execute("UPDATE v3.project SET object_address = '' WHERE object_address IS NULL")
op.execute("UPDATE v3.project SET placement_type = 'own' WHERE placement_type IS NULL")
op.execute("ALTER TABLE v3.project ALTER COLUMN name SET NOT NULL")
op.execute("ALTER TABLE v3.project ALTER COLUMN project_type SET NOT NULL")
op.execute("ALTER TABLE v3.project ALTER COLUMN vsp_format SET NOT NULL")
op.execute("ALTER TABLE v3.project ALTER COLUMN object_address SET NOT NULL")
op.execute("ALTER TABLE v3.project ALTER COLUMN placement_type SET NOT NULL")
# Числовые значения проекта не могут быть отрицательными:
# сначала обнуляем уже существующие отрицательные значения,
# затем накладываем ограничения
op.execute("UPDATE v3.project SET staff_count = 0 WHERE staff_count < 0")
op.execute("UPDATE v3.project SET total_area = 0 WHERE total_area < 0")
op.execute(
"ALTER TABLE v3.project ADD CONSTRAINT project_staff_count_check "
"CHECK (staff_count IS NULL OR staff_count >= 0)"
)
op.execute(
"ALTER TABLE v3.project ADD CONSTRAINT project_total_area_check "
"CHECK (total_area IS NULL OR total_area >= 0)"
)
def downgrade() -> None:
pass

View File

@ -126,6 +126,27 @@ async def export_forms_bulk(
)
@router.get("/project/{project_id}")
async def export_project(
project_id: int,
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_payload(
project_id=project_id,
current_user=current_user,
)
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)},
)
@router.get("/project/{project_id}/report/{year}/{report_type}")
async def export_project_report(
project_id: int,

View File

@ -14,7 +14,6 @@ from src.db.session import get_db
from src.domain.schemas import (
AddForm3LineBody,
AddProjectBody,
AddProjectYearBody,
BaseListResponse,
BaseSingleResponse,
CellPatch,
@ -329,14 +328,12 @@ async def delete_project(
@router.post("/projects/{project_id}/year")
async def add_project_year(
project_id: int,
body: AddProjectYearBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
) -> dict:
project_service = ProjectService(db)
return await project_service.add_project_year(
project_id=project_id,
year=body.year,
user=current_user,
)

View File

@ -17,7 +17,7 @@ class Project(Base):
name="project_placement_type_check",
),
CheckConstraint(
"project_type IS NULL OR project_type IN ('current', 'development')",
"project_type IS NULL OR project_type IN ('Открытие ВСП', 'Закрытие ВСП', 'Переезд ВСП', 'Реновация РФ', 'Открытие УРМ')",
name="project_project_type_check",
),
CheckConstraint(
@ -36,18 +36,26 @@ class Project(Base):
"funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve')",
name="project_funding_ko_check",
),
CheckConstraint(
"staff_count IS NULL OR staff_count >= 0",
name="project_staff_count_check",
),
CheckConstraint(
"total_area IS NULL OR total_area >= 0",
name="project_total_area_check",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String, nullable=False)
level: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
project_type: Mapped[str | None] = mapped_column(String)
vsp_format: Mapped[str | None] = mapped_column(String)
placement_type: Mapped[str | None] = mapped_column(String)
object_address: Mapped[str | None] = mapped_column(String)
project_type: Mapped[str] = mapped_column(String, nullable=False)
vsp_format: Mapped[str] = mapped_column(String, nullable=False)
placement_type: Mapped[str] = mapped_column(String, nullable=False)
object_address: Mapped[str] = mapped_column(String, nullable=False)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))

View File

@ -388,8 +388,8 @@ class UpdProjectBody(BaseModel):
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = None
total_area: Optional[float] = None
staff_count: Optional[int] = Field(None, ge=0)
total_area: Optional[float] = Field(None, ge=0)
org_unit_id: Optional[int] = None
krf_decision_date: Optional[date] = None
fk_decision_date: Optional[date] = None
@ -398,10 +398,6 @@ class UpdProjectBody(BaseModel):
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
class AddProjectYearBody(BaseModel):
year: int
class UpdSmetaBody(BaseModel):
is_in_plan: Optional[bool] = None
is_in_plan_q2: Optional[bool] = None
@ -423,12 +419,12 @@ class AddProjectBody(BaseModel):
level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None
ssp_id: Optional[int] = None
project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = None
total_area: Optional[float] = None
project_type: ProjectTypeLiteral
vsp_format: VspFormatLiteral
placement_type: PlacementTypeLiteral
object_address: str = Field(..., min_length=1)
staff_count: Optional[int] = Field(None, ge=0)
total_area: Optional[float] = Field(None, ge=0)
@field_validator("year", mode="before")
@classmethod

View File

@ -480,6 +480,22 @@ class ProjectRepository:
)
await self.db.flush()
async def get_max_year(
self,
project_id: int,
org_unit_ids: list[int] | None = None,
) -> int | None:
query = (
select(func.max(RfProjectYear.year))
.join(Project, Project.id == RfProjectYear.project_id)
.where(Project.id == project_id)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return (await self.db.execute(query)).scalar_one_or_none()
async def resolve_rf_project_year_id(
self,
project_id: int,

View File

@ -1426,6 +1426,15 @@ PALETTE = {
"INPUT": "FFFFFF",
"COLOR": "000000",
},
"project": {
"ROOT": "C2D59A",
"GROUP": "D7E3BC",
"ITEM": "EAF0DD",
"SUB_ITEM": "FFFFFF",
"SUB_ITEM": "EAF0DD",
"INPUT": "FFFFFF",
"COLOR": "000000",
},
"orange": {
"ROOT": "F1C297",
"GROUP": "F6D6B9",
@ -3132,13 +3141,389 @@ 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", "ИТОГО"),
]
FORM3_LIMIT_DEPTH_COLUMNS: dict = {
"header": {
"name": "Данные",
"children": {
"section_code": {"name": "Код раздела"},
"item_id": {"name": "ID Статьи"},
"num_group_id": {"name": "ID Группы номенклатуры"},
"name": {"name": "Наименование"},
},
},
"q1": {
"name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в I квартале",
"children": {
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За январь"},
"m2": {"name": "За февраль"},
"m3": {"name": "За март"},
"quarter_actual": {"name": "Итого I квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q2": {
"name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в II квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на II квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За апрель"},
"m2": {"name": "За май"},
"m3": {"name": "За июнь"},
"quarter_actual": {"name": "Итого II квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q3": {
"name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в III квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на III квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За июль"},
"m2": {"name": "За август"},
"m3": {"name": "За сентябрь"},
"quarter_actual": {"name": "Итого III квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q4": {
"name": "Отчет об исполнении лимита затрат, утверждённого Правлением Банка в IV квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на IV квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За октябрь"},
"m2": {"name": "За ноябрь"},
"m3": {"name": "За декабрь"},
"spod": {"name": "СПОД"},
"quarter_actual": {"name": "Итого IV квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
}
FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS: dict = {
"header": {
"name": "Данные",
"children": {
"section_code": {"name": "Код раздела"},
"item_id": {"name": "ID Статьи"},
"num_group_id": {"name": "ID Группы номенклатуры"},
"name": {"name": "Наименование"},
},
},
"q1": {
"name": "Отчет об исполнении текущих расходов в I квартале",
"children": {
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За январь"},
"m2": {"name": "За февраль"},
"m3": {"name": "За март"},
"quarter_actual": {"name": "Итого I квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q2": {
"name": "Отчет об исполнении текущих расходов в II квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на II квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За апрель"},
"m2": {"name": "За май"},
"m3": {"name": "За июнь"},
"quarter_actual": {"name": "Итого II квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q3": {
"name": "Отчет об исполнении текущих расходов в III квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на III квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За июль"},
"m2": {"name": "За август"},
"m3": {"name": "За сентябрь"},
"quarter_actual": {"name": "Итого III квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
"q4": {
"name": "Отчет об исполнении текущих расходов в IV квартале",
"children": {
"limit_ostatok": {
"key": None,
"name": "Лимит затрат / остаток лимита затрат на IV квартал",
"children": {"carryover": {"name": ""}},
},
"korrektirovka_limita": {
"key": None,
"name": "Корректировка лимита",
"children": {
"adj_by_items": {"name": "По статьям сметы*"},
"adj_increase": {"name": "Увеличение сметы**"},
},
},
"itogo": {
"key": None,
"name": "Итого",
"children": {"total_corr": {"name": ""}},
},
"fakt": {
"key": None,
"name": "Факт",
"children": {
"m1": {"name": "За октябрь"},
"m2": {"name": "За ноябрь"},
"m3": {"name": "За декабрь"},
"spod": {"name": "СПОД"},
"quarter_actual": {"name": "Итого IV квартал"},
},
},
"ekonomiya": {
"key": None,
"name": "Экономия (+), перерасход (-)",
"children": {"economy": {"name": ""}},
},
},
},
}
FORM3_COLORS: dict = {
"header": {"background": "C2D69B", "palette": "green"},
"header.section_code": {"background": "C2D69B", "palette": "green"},
"header.item_id": {"background": "C2D69B", "palette": "green"},
"header.num_group_id": {"background": "C2D69B", "palette": "green"},
"header.name": {"background": "C2D69B", "palette": "green"},
"q1": {"background": "C2D69B", "palette": "green"},
"q1.korrektirovka_limita": {"background": "C2D69B", "palette": "green"},
"q1.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"},
"q1.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"},
"q1.itogo": {"background": "C2D69B", "palette": "green"},
"q1.itogo.total_corr": {"background": "C2D69B", "palette": "green"},
"q1.fakt": {"background": "C2D69B", "palette": "green"},
"q1.fakt.m1": {"background": "C2D69B", "palette": "green"},
"q1.fakt.m2": {"background": "C2D69B", "palette": "green"},
"q1.fakt.m3": {"background": "C2D69B", "palette": "green"},
"q1.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"},
"q1.ekonomiya": {"background": "C2D69B", "palette": "green"},
"q1.ekonomiya.economy": {"background": "C2D69B", "palette": "green"},
"q2": {"background": "C2D69B", "palette": "green"},
"q2.limit_ostatok": {"background": "C2D69B", "palette": "green"},
"q2.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"},
"q2.korrektirovka_limita": {"background": "C2D69B", "palette": "green"},
"q2.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"},
"q2.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"},
"q2.itogo": {"background": "C2D69B", "palette": "green"},
"q2.itogo.total_corr": {"background": "C2D69B", "palette": "green"},
"q2.fakt": {"background": "C2D69B", "palette": "green"},
"q2.fakt.m1": {"background": "C2D69B", "palette": "green"},
"q2.fakt.m2": {"background": "C2D69B", "palette": "green"},
"q2.fakt.m3": {"background": "C2D69B", "palette": "green"},
"q2.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"},
"q2.ekonomiya": {"background": "C2D69B", "palette": "green"},
"q2.ekonomiya.economy": {"background": "C2D69B", "palette": "green"},
"q3": {"background": "C2D69B", "palette": "green"},
"q3.limit_ostatok": {"background": "C2D69B", "palette": "green"},
"q3.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"},
"q3.korrektirovka_limita": {"background": "C2D69B", "palette": "green"},
"q3.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"},
"q3.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"},
"q3.itogo": {"background": "C2D69B", "palette": "green"},
"q3.itogo.total_corr": {"background": "C2D69B", "palette": "green"},
"q3.fakt": {"background": "C2D69B", "palette": "green"},
"q3.fakt.m1": {"background": "C2D69B", "palette": "green"},
"q3.fakt.m2": {"background": "C2D69B", "palette": "green"},
"q3.fakt.m3": {"background": "C2D69B", "palette": "green"},
"q3.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"},
"q3.ekonomiya": {"background": "C2D69B", "palette": "green"},
"q3.ekonomiya.economy": {"background": "C2D69B", "palette": "green"},
"q4": {"background": "C2D69B", "palette": "green"},
"q4.limit_ostatok": {"background": "C2D69B", "palette": "green"},
"q4.limit_ostatok.carryover": {"background": "C2D69B", "palette": "green"},
"q4.korrektirovka_limita": {"background": "C2D69B", "palette": "green"},
"q4.korrektirovka_limita.adj_by_items": {"background": "C2D69B", "palette": "green"},
"q4.korrektirovka_limita.adj_increase": {"background": "C2D69B", "palette": "green"},
"q4.itogo": {"background": "C2D69B", "palette": "green"},
"q4.itogo.total_corr": {"background": "C2D69B", "palette": "green"},
"q4.fakt": {"background": "C2D69B", "palette": "green"},
"q4.fakt.m1": {"background": "C2D69B", "palette": "green"},
"q4.fakt.m2": {"background": "C2D69B", "palette": "green"},
"q4.fakt.m3": {"background": "C2D69B", "palette": "green"},
"q4.fakt.spod": {"background": "C2D69B", "palette": "green"},
"q4.fakt.quarter_actual": {"background": "C2D69B", "palette": "green"},
"q4.ekonomiya": {"background": "C2D69B", "palette": "green"},
"q4.ekonomiya.economy": {"background": "C2D69B", "palette": "green"},
}

View File

@ -246,38 +246,25 @@ class SmetaRowNormalizer:
return cur
class Form3ReportRowNormalizer:
class Form3DepthRowNormalizer:
def __init__(self, serializer: ExcelValueSerializer):
self.serializer = serializer
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)
normalized: dict[str, Any] = {}
self._normalize_depth(normalized=normalized, original=data)
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
def _normalize_depth(
self,
normalized: dict[str, Any],
original: dict[str, Any],
prefix: str = "",
) -> None:
for key, value in original.items():
if key == "line_id":
continue
if isinstance(value, dict):
self._normalize_depth(normalized=normalized, original=value, prefix=f"{prefix}{key}.")
else:
normalized[f"{prefix}{key}"] = value

View File

@ -18,14 +18,16 @@ from src.services.budget_form_service import BudgetFormService
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1DepthRowNormalizer,
Form3ReportRowNormalizer,
Form3DepthRowNormalizer,
SmetaRowNormalizer,
)
from src.services.export_service.export_mappings import (
FORM1_COLORS,
FORM1_DEPTH_COLUMNS,
FORM2_DEPTH_AHR_SUB_COLUMNS,
FORM3_REPORT_COLUMNS,
FORM3_COLORS,
FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS,
FORM3_LIMIT_DEPTH_COLUMNS,
SMETA_COLUMNS,
)
from src.services.export_service.export_writers import (
@ -50,6 +52,8 @@ SHEET_NAMES = {
'AHR_UTILITY': 'АХР Коммунальные услуги',
'SMETA': 'Смета',
'STRUCTURE': 'Структура',
'LIMIT': 'Лимиты',
'CURRENT_EXPENSES': 'Текущие расходы',
}
SKIP_SHEETS = ('OTCH9F',)
@ -69,7 +73,7 @@ class ExportService:
self.project_service = ProjectService(db)
self.value_serializer = ExcelValueSerializer()
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
self.form3_report_row_normalizer = Form3ReportRowNormalizer()
self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer)
self.smeta_row_normalizer = SmetaRowNormalizer()
self.sheet_writers: dict[str, TabularSheetWriter] = {
"__default__": DepthSheetWriter(
@ -122,11 +126,6 @@ class ExportService:
colors_config=FORM1_COLORS["AHR"],
),
}
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,
@ -164,6 +163,17 @@ class ExportService:
)
return stream, filename, XLSX_MEDIA_TYPE
async def export_project_payload(
self,
project_id: int,
current_user: AppUser,
) -> tuple[io.BytesIO, str, str]:
stream, filename = await self.export_project_to_xlsx(
project_id=project_id,
current_user=current_user,
)
return stream, filename, XLSX_MEDIA_TYPE
async def export_bulk_payload(
self,
form_ids: list[int],
@ -300,14 +310,20 @@ class ExportService:
user=current_user,
)
report_type_upper = report_type.upper()
project = await self.project_service.get(project_id, current_user)
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),
("Название проекта", project.get("name") if project else None),
("Технический номер", project.get("technical_number") if project else None),
("Тип проекта", project.get("project_type") if project else None),
("Формат ВСП", project.get("vsp_format") if project else None),
("ССП/РФ", project.get("org_unit_name") if project else None),
("Статус", project.get("status") if project else None),
("Год", year),
("Тип отчёта", report_type_upper),
("Sections", ", ".join(sections) if sections else None),
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
]
@ -315,7 +331,13 @@ class ExportService:
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)
report_writer = DepthSheetWriter(
columns_dict=self._form3_columns(report_type_upper),
row_normalizer=self.form3_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM3_COLORS,
)
report_writer.write(worksheet, rows)
stream = io.BytesIO()
workbook.save(stream)
workbook.close()
@ -324,11 +346,75 @@ class ExportService:
filename = f"{year}_FORM_3_{project_id}_{report_type_upper}_{date_str}.xlsx"
return stream, filename
async def export_project_to_xlsx(
self,
project_id: int,
current_user: AppUser,
) -> tuple[io.BytesIO, str]:
project = await self.project_service.get(project_id, current_user)
if not project:
raise ValueError(f"Проект {project_id} не найден")
reports = project.get("reports") or []
if not reports:
raise ValueError("У проекта нет отчётов для экспорта")
workbook = Workbook()
meta_sheet = workbook.active
meta_sheet.title = "metadata"
metadata_rows = [
("Тип формы", "FORM_3"),
("Название проекта", project.get("name")),
("Технический номер", project.get("technical_number")),
("Тип проекта", project.get("project_type")),
("Формат ВСП", project.get("vsp_format")),
("ССП/РФ", project.get("org_unit_name")),
("Статус", project.get("status")),
("Экспортировано", 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)
for report in reports:
report_type = str(report.get("report_type", "")).upper()
year = report.get("year")
rows = await self.project_service.get_report_rows(
project_id=project_id,
year=year,
report_type=report_type,
sections=None,
user=current_user,
)
sheet_title = f"{year}_{self._safe_sheet_name(report_type)}"
worksheet = workbook.create_sheet(title=sheet_title)
report_writer = DepthSheetWriter(
columns_dict=self._form3_columns(report_type),
row_normalizer=self.form3_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM3_COLORS,
)
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"project_{project_id}_FORM_3_{date_str}.xlsx"
return stream, filename
@staticmethod
def build_content_disposition(filename: str) -> str:
encoded = quote(filename)
return f"attachment; filename*=UTF-8''{encoded}"
@staticmethod
def _form3_columns(report_type: str) -> dict:
if (report_type or "").upper() == "LIMIT":
return FORM3_LIMIT_DEPTH_COLUMNS
return FORM3_CURRENT_EXPENSES_DEPTH_COLUMNS
def _fill_metadata(self, sheet: Worksheet, form: Any) -> None:
metadata_rows = [
("Форма ID", form.id),

View File

@ -224,7 +224,6 @@ class ProjectService:
async def add_project_year(
self,
project_id: int,
year: int,
user: AppUser,
) -> dict:
org_unit_ids = await self._allowed_org_unit_ids(user)
@ -234,14 +233,14 @@ class ProjectService:
)
if not project:
raise ValidationException("Проект не найден")
existing = await self.project_repo.resolve_rf_project_year_id(
last_year = await self.project_repo.get_max_year(
project_id=project_id,
year=year,
org_unit_ids=org_unit_ids,
)
if existing is not None:
raise ValidationException(f"Год {year} у проекта уже заведён")
return await self.project_repo.add_project_year(project_id=project_id, year=year)
if not last_year:
raise ValidationException("У проекта нет года")
next_year = last_year + 1
return await self.project_repo.add_project_year(project_id=project_id, year=next_year)
async def add_project(
self,

View File

@ -123,7 +123,15 @@ def test_backend_calls_form3_add_project_and_add_line_functions(
):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
json={
"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}",
"year": 2026,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
@ -214,7 +222,15 @@ def test_forms_validation_direction_forbidden_on_non_form1(
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
json={
"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}",
"year": 2026,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
@ -232,7 +248,15 @@ def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, aut
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
json={
"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}",
"year": 2026,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
@ -248,7 +272,15 @@ def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, au
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
json={
"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}",
"year": 2026,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
@ -265,7 +297,15 @@ def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_h
# Пробел запрещён regex-правилом AddProjectBody.
response = client.post(
"/api/v1/projects",
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
json={
"name": "INVALID NAME",
"year": 2026,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422

View File

@ -18,7 +18,15 @@ def _create_project(client, admin_tokens, auth_headers, year=2026, branch_id=1)
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": year, "branch_id": branch_id},
json={
"name": name,
"year": year,
"branch_id": branch_id,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
@ -54,7 +62,15 @@ def test_create_project_smoke(client, admin_tokens, auth_headers):
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": None, "branch_id": 1},
json={
"name": name,
"year": None,
"branch_id": 1,
"project_type": "Открытие ВСП",
"vsp_format": "Типовой",
"placement_type": "Собственность",
"object_address": "Тестовая улица 1",
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
@ -361,7 +377,7 @@ def test_add_project_year_smoke(client, admin_tokens, auth_headers):
response = client.post(
f"/api/v1/projects/{project_id}/year",
json={"year": 2027},
json={"year": 2050},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
@ -377,7 +393,7 @@ def test_add_project_year_smoke(client, admin_tokens, auth_headers):
assert 2027 in sub_years
def test_add_project_year_duplicate(client, admin_tokens, auth_headers):
def test_add_project_year_ignores_requested_year(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
response = client.post(
@ -385,9 +401,9 @@ def test_add_project_year_duplicate(client, admin_tokens, auth_headers):
json={"year": 2026},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
assert response.status_code == 200
payload = response.json()
assert payload.get("message") == "Год 2026 у проекта уже заведён"
assert payload["year"] == 2027
def test_add_project_year_project_not_found(client, admin_tokens, auth_headers):

View File

@ -35,7 +35,7 @@ INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_t
SELECT setval('v3.vsp_id_seq', 3);
INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id,status,technical_number,krf_decision_date,fk_decision_date,board_decision_date,open_relocate_close_date,funding_by_ko_decision) VALUES
(2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget');
(2,'PT6d112310_U','project',NULL,'Открытие ВСП','Типовой','own','Тестовая улица 1',NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget');
SELECT setval('v3.project_id_seq', 3);
INSERT INTO v3.rf_project_year ("id",project_id,"year") VALUES

View File

@ -1,7 +1,7 @@
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1RowNormalizer,
Form3ReportRowNormalizer,
Form3DepthRowNormalizer,
SmetaRowNormalizer,
)
@ -52,24 +52,29 @@ def test_excel_value_serializer_formats_collections():
assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1"
def test_form3_normalizer_flattens_quarters_and_totals_to_scalars():
normalizer = Form3ReportRowNormalizer()
def test_form3_depth_normalizer_flattens_nested_data():
normalizer = Form3DepthRowNormalizer(ExcelValueSerializer())
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},
"line_id": 5,
"header": {"section_code": "1.00.0.", "item_id": "R001", "num_group_id": 7, "name": "Строка"},
"q1": {"base_plan": 1, "adj_by_items": 2, "m1": 10, "quarter_actual": 20, "economy": 3},
"q2": {"corrected_plan": 4, "carryover": 5},
"totals": {"base_plan": 100, "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
assert normalized["header.section_code"] == "1.00.0."
assert normalized["header.item_id"] == "R001"
assert normalized["header.num_group_id"] == 7
assert normalized["header.name"] == "Строка"
assert normalized["q1.base_plan"] == 1
assert normalized["q1.adj_by_items"] == 2
assert normalized["q1.m1"] == 10
assert normalized["q1.quarter_actual"] == 20
assert normalized["q2.corrected_plan"] == 4
assert normalized["q2.carryover"] == 5
assert normalized["totals.base_plan"] == 100
assert normalized["totals.total_actual"] == 40
assert normalized["totals.economy"] == 2.1
assert "line_id" not in normalized

View File

@ -43,4 +43,9 @@ export const ProjectsApi = {
responseType: 'blob',
});
},
exportProject: (id) => {
return api.get(`/export/project/${id}`, {
responseType: 'blob',
});
},
};

View File

@ -17,7 +17,7 @@ import TablesList from '../../components/common/TableList/TableList';
import { TableIcon } from '../../components/common/icons/icons';
import { Chip } from '../../components/styles/StyledChip';
import { FORM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants/constants';
import { exportSingleForm } from '../../utils/exportFile';
import { exportProject, exportSingleForm } from '../../utils/exportFile';
const TaskInfo = ({ task, isProject }) => {
const portalContent = (
@ -113,7 +113,12 @@ export default function TaskPage() {
// Функция для экспорта
const handleExport = () => {
const fileName = isProject ? `project_${task?.title || id}` : `form_${task?.title || taskId}`;
if (isProject) {
const fileName = `project_${task?.name || id}`;
exportProject(id, fileName);
return;
}
const fileName = `form_${task?.title || taskId}`;
exportSingleForm(id, fileName);
};

View File

@ -37,6 +37,16 @@ export const exportSheetProject = async (formId, sheetName, year) => {
}
};
export const exportProject = async (projectId, projectTitle) => {
try {
const response = await ProjectsApi.exportProject(projectId);
await downloadFile(response, projectTitle, 'xlsx');
} catch (error) {
const message = await getApiErrorMessage(error, 'Ошибка при скачивании файла');
toast.error(message);
}
};
export const exportBulkForms = async (formIds, fileName = 'Задачи') => {
try {
const response = await TasksApi.exportTasks({ form_ids: formIds });