project-add-year-handler: ручка добавления года #94

Merged
tsygankoviva merged 1 commits from project-add-year-handler into test 2026-08-14 15:05:45 +03:00
5 changed files with 170 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@ -62,7 +62,13 @@ 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:
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)
@ -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,

View File

@ -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") == "Проект не найден"