project-add-year-handler: ручка добавления года #94
@ -5,12 +5,16 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.app_user import AppUser
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.domain.schemas import (
|
from src.domain.schemas import (
|
||||||
AddForm3LineBody,
|
AddForm3LineBody,
|
||||||
AddProjectBody,
|
AddProjectBody,
|
||||||
|
AddProjectYearBody,
|
||||||
BaseListResponse,
|
BaseListResponse,
|
||||||
BaseSingleResponse,
|
BaseSingleResponse,
|
||||||
CellPatch,
|
CellPatch,
|
||||||
@ -326,6 +330,21 @@ async def delete_project(
|
|||||||
return ResponseBase(success=True, message="Проект удалён")
|
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")
|
@router.post("/projects")
|
||||||
async def add_project(
|
async def add_project(
|
||||||
body: AddProjectBody,
|
body: AddProjectBody,
|
||||||
|
|||||||
@ -399,6 +399,10 @@ class UpdProjectBody(BaseModel):
|
|||||||
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
|
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddProjectYearBody(BaseModel):
|
||||||
|
year: int
|
||||||
|
|
||||||
|
|
||||||
class UpdSmetaBody(BaseModel):
|
class UpdSmetaBody(BaseModel):
|
||||||
is_in_plan: Optional[bool] = None
|
is_in_plan: Optional[bool] = None
|
||||||
is_in_plan_q2: Optional[bool] = None
|
is_in_plan_q2: Optional[bool] = None
|
||||||
|
|||||||
@ -109,6 +109,21 @@ class ProjectRepository:
|
|||||||
total = int((await self.db.execute(count_query)).scalar() or 0)
|
total = int((await self.db.execute(count_query)).scalar() or 0)
|
||||||
return total, payload
|
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
|
@staticmethod
|
||||||
def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict:
|
def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict:
|
||||||
return {
|
return {
|
||||||
@ -132,18 +147,7 @@ class ProjectRepository:
|
|||||||
"funding_by_ko_decision": project.funding_by_ko_decision,
|
"funding_by_ko_decision": project.funding_by_ko_decision,
|
||||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||||
"sub_rows": [
|
"sub_rows": [
|
||||||
{
|
ProjectRepository._serialize_sub_row(rpy, smeta)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
for rpy, smeta in years
|
for rpy, smeta in years
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -546,6 +550,34 @@ class ProjectRepository:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._serialize_smeta(smeta)
|
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(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@ -62,7 +62,13 @@ class ProjectService:
|
|||||||
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
||||||
return project
|
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)
|
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)
|
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)
|
await self.project_repo.upd_smeta(rf_project_year_id, data)
|
||||||
return await self.project_repo.get_smeta(rf_project_year_id)
|
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(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@ -337,3 +337,75 @@ def test_upd_smeta_not_found(client, admin_tokens, auth_headers):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload.get("message") == "Смета не найдена"
|
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") == "Проект не найден"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user