From 0245b0c3b24cc1b863e99d0c75a12be1c572cdba Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Fri, 14 Aug 2026 15:03:18 +0300 Subject: [PATCH] =?UTF-8?q?project-add-year-handler:=20=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B3=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/v1/projects.py | 21 +++++- api/src/domain/schemas.py | 4 ++ api/src/repository/project_repository.py | 56 +++++++++++---- api/src/services/project_service.py | 32 ++++++++- .../integration/test_projects_api_smoke.py | 72 +++++++++++++++++++ 5 files changed, 170 insertions(+), 15 deletions(-) diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index e32b5b7..98c8825 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -5,12 +5,16 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy.ext.asyncio import AsyncSession -from src.api.v1.deps import get_current_active_user_with_set_db +from src.api.v1.deps import ( + get_current_active_user_with_set_db, + require_executor, +) from src.db.models.app_user import AppUser from src.db.session import get_db from src.domain.schemas import ( AddForm3LineBody, AddProjectBody, + AddProjectYearBody, BaseListResponse, BaseSingleResponse, CellPatch, @@ -326,6 +330,21 @@ async def delete_project( return ResponseBase(success=True, message="Проект удалён") +@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, + ) + + @router.post("/projects") async def add_project( body: AddProjectBody, diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 136e1bf..701309c 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -399,6 +399,10 @@ 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 diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 6431638..dc46872 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -109,6 +109,21 @@ class ProjectRepository: total = int((await self.db.execute(count_query)).scalar() or 0) return total, payload + @staticmethod + def _serialize_sub_row(rpy: RfProjectYear, smeta: Smeta | None) -> dict: + return { + "id": rpy.id, + "year": rpy.year, + "project": f"Смета_{rpy.year}", + "is_in_plan": smeta.is_in_plan if smeta else None, + "is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None, + "is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None, + "is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None, + "development_block": smeta.development_block if smeta else None, + "reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None, + "reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None, + } + @staticmethod def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict: return { @@ -132,18 +147,7 @@ class ProjectRepository: "funding_by_ko_decision": project.funding_by_ko_decision, "created_at": project.created_at.isoformat() if project.created_at else None, "sub_rows": [ - { - "id": rpy.id, - "year": rpy.year, - "project": f"Смета_{rpy.year}", - "is_in_plan": smeta.is_in_plan if smeta else None, - "is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None, - "is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None, - "is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None, - "development_block": smeta.development_block if smeta else None, - "reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None, - "reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None, - } + ProjectRepository._serialize_sub_row(rpy, smeta) for rpy, smeta in years ], } @@ -546,6 +550,34 @@ class ProjectRepository: ).scalar_one_or_none() return self._serialize_smeta(smeta) + async def add_project_year(self, project_id: int, year: int) -> dict | None: + query = text( + """ + SELECT limit_report_id, current_expenses_report_id + FROM v3.add_project_year( + CAST(:project_id AS INT), + CAST(:year AS INT) + ) + """ + ) + await self.db.execute(query, {"project_id": project_id, "year": year}) + await self.db.flush() + + row = ( + await self.db.execute( + select(RfProjectYear, Smeta) + .outerjoin(Smeta, Smeta.rf_project_year_id == RfProjectYear.id) + .where( + RfProjectYear.project_id == project_id, + RfProjectYear.year == year, + ) + ) + ).first() + if not row: + return None + rpy, smeta = row + return self._serialize_sub_row(rpy, smeta) + async def add_project( self, name: str, diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index 0a9b02e..fa92a1f 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -62,8 +62,14 @@ class ProjectService: project["reports"] = await self.project_repo.get_reports(project_id=project_id) return project - async def get_instance(self, project_id: int, user: AppUser) -> Project | None: - org_unit_ids = await self._allowed_org_unit_ids(user) + async def get_instance( + self, + project_id: int, + user: AppUser | None = None, + org_unit_ids: list[int] | None = None, + ) -> Project | None: + if org_unit_ids is None and user is not None: + org_unit_ids = await self._allowed_org_unit_ids(user) return await self.project_repo.get_instance(project_id=project_id, org_unit_ids=org_unit_ids) async def resolve_report_id( @@ -215,6 +221,28 @@ class ProjectService: await self.project_repo.upd_smeta(rf_project_year_id, data) return await self.project_repo.get_smeta(rf_project_year_id) + async def add_project_year( + self, + project_id: int, + year: int, + user: AppUser, + ) -> dict: + org_unit_ids = await self._allowed_org_unit_ids(user) + project = await self.get_instance( + project_id=project_id, + org_unit_ids=org_unit_ids, + ) + if not project: + raise ValidationException("Проект не найден") + existing = await self.project_repo.resolve_rf_project_year_id( + 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) + async def add_project( self, name: str, diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 3dc92e6..7114a6d 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -337,3 +337,75 @@ def test_upd_smeta_not_found(client, admin_tokens, auth_headers): assert response.status_code == 400 payload = response.json() assert payload.get("message") == "Смета не найдена" + + +def test_add_project_year_smoke(client, admin_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) + + response = client.post( + f"/api/v1/projects/{project_id}/year", + json={"year": 2027}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["year"] == 2027 + assert payload["project"] == "Смета_2027" + assert payload["id"] is not None + + project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id) + assert project is not None + sub_years = {sr["year"] for sr in project["sub_rows"]} + assert 2026 in sub_years + assert 2027 in sub_years + + +def test_add_project_year_duplicate(client, admin_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026) + + response = client.post( + f"/api/v1/projects/{project_id}/year", + json={"year": 2026}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert payload.get("message") == "Год 2026 у проекта уже заведён" + + +def test_add_project_year_project_not_found(client, admin_tokens, auth_headers): + response = client.post( + "/api/v1/projects/99999/year", + json={"year": 2027}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert payload.get("message") == "Проект не найден" + + +def test_add_project_year_by_executor(client, admin_tokens, isp_tokens, auth_headers): + project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=2, year=2026) + + response = client.post( + f"/api/v1/projects/{project_id}/year", + json={"year": 2027}, + headers=auth_headers(isp_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["year"] == 2027 + + +def test_add_project_year_by_executor_from_other_org(client, admin_tokens, isp_tokens, auth_headers): + """Исполнитель, привязанный к РФ id=2, не может добавить год проекту на ССП id=1 — ACL.""" + project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=1, year=2026) + + response = client.post( + f"/api/v1/projects/{project_id}/year", + json={"year": 2027}, + headers=auth_headers(isp_tokens), + ) + assert response.status_code == 400 + payload = response.json() + assert payload.get("message") == "Проект не найден" -- 2.47.2