Files
zkxw/backend/app/api/project_management.py
T
zlwu bd62416263 feat(project): add team attribution and business statistics
Add tenant-scoped teams and projects, order attribution across four businesses, conversion inheritance, scoped statistics and audited reassignment. Reuse existing Web and mobile UI patterns; team regions and hospitals remain informational.

Include incremental SQL and isolated regression coverage. Actual MySQL and browser/WebView integration validation remains pending; relates to issue #13.
2026-11-02 15:29:43 +08:00

174 lines
7.6 KiB
Python

"""项目管理;所有管理路由显式校验精确菜单权限。"""
from typing import Optional, List, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import success_response
from app.services import project_management as svc
router = APIRouter(tags=['项目管理'])
PREFIX = '/web/admin/project-management'
class Body(BaseModel):
model_config = ConfigDict(extra='forbid')
class Member(Body):
id: Optional[str] = None
userId: str
class Region(Body):
id: Optional[str] = None
regionId: str
class Hospital(Body):
id: Optional[str] = None
hospitalId: str
class TeamBody(Body):
teamName: str = Field(min_length=1, max_length=200)
leaderUserId: str
enabled: bool = True
members: List[Member] = Field(default_factory=list)
regions: List[Region] = Field(default_factory=list)
hospitals: List[Hospital] = Field(default_factory=list)
remark: Optional[str] = Field(None, max_length=512)
class ProjectBody(Body):
projectName: str = Field(min_length=1, max_length=200)
teamId: str
leaderUserId: str
bizTypes: List[str]
startDate: Optional[str] = None
endDate: Optional[str] = None
status: Literal['pending', 'active', 'completed', 'disabled'] = 'pending'
remark: Optional[str] = Field(None, max_length=512)
class PreviewBody(Body):
bizType: Literal['presign', 'cell_storage', 'research_donation', 'pilot_service', 'basic_experiment']
id: str
projectId: str
class Snapshot(BaseModel):
bizType: str
id: str
projectId: Optional[str] = None
class AssignmentBody(PreviewBody):
reason: str = Field(min_length=1, max_length=512)
expectedAssignments: List[Snapshot]
def identity(user=Depends(get_current_user)):
tenant = user.get('tenant_no')
if not tenant or not user.get('user_id'):
raise HTTPException(403, '缺少认证租户')
return tenant, user
def guard(*codes):
def dependency(ctx=Depends(identity), db: Session=Depends(get_db)):
svc.check_permission(db, ctx[0], ctx[1], *codes)
return ctx
return dependency
@router.get('/project-management/options')
def options(bizType: str, usage: Literal['assignment', 'filter']='assignment', keyword: Optional[str]=None, currentProjectId: Optional[str]=None, ctx=Depends(identity), db: Session=Depends(get_db)):
# Arbitrary currentProjectId cannot authorize reading historical project metadata.
if usage == 'filter':
require_admin_or_super_admin(ctx[1])
method = svc.filter_options if usage == 'filter' else svc.options
return success_response(method(db, *ctx, bizType, keyword))
@router.get(PREFIX + '/references')
def references(ctx=Depends(guard('project:team:list', 'project:team:edit', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.references(db, ctx[0]))
@router.get(PREFIX + '/teams')
def teams(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, leaderUserId: Optional[str]=None, enabled: Optional[bool]=None, ctx=Depends(guard('project:team:list', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.list_entities(db, ctx[0], svc.ProjectTeam, page, pageSize, keyword=keyword, leaderUserId=leaderUserId, enabled=enabled))
@router.get(PREFIX + '/projects')
def projects(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, teamId: Optional[str]=None, status: Optional[str]=None, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)):
return success_response(svc.list_entities(db, ctx[0], svc.Project, page, pageSize, keyword=keyword, teamId=teamId, status=status))
@router.post(PREFIX + '/teams')
def create_team(body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump()))
@router.post(PREFIX + '/projects')
def create_project(body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump()))
@router.get(PREFIX + '/teams/{row_id}')
def get_team(row_id: str, ctx=Depends(guard('project:team:list')), db: Session=Depends(get_db)):
return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.ProjectTeam, ctx[0], row_id)))
@router.get(PREFIX + '/projects/{row_id}')
def get_project(row_id: str, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)):
return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.Project, ctx[0], row_id)))
@router.put(PREFIX + '/teams/{row_id}')
def update_team(row_id: str, body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump(), row_id))
@router.put(PREFIX + '/projects/{row_id}')
def update_project(row_id: str, body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump(), row_id))
@router.delete(PREFIX + '/teams/{row_id}')
def delete_team(row_id: str, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.delete_entity(db, *ctx, svc.ProjectTeam, row_id))
@router.delete(PREFIX + '/projects/{row_id}')
def delete_project(row_id: str, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.delete_entity(db, *ctx, svc.Project, row_id))
@router.get(PREFIX + '/statistics')
def statistics(groupBy: str='project', teamId: Optional[str]=None, projectId: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db)):
return success_response(svc.statistics(db, *ctx, groupBy=groupBy, teamId=teamId, projectId=projectId, startDate=startDate, endDate=endDate))
@router.get(PREFIX + '/records')
def records(bizType: str, page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), teamId: Optional[str]=None, projectId: Optional[str]=None, unassigned: bool=False, status: Optional[str]=None, keyword: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list', 'project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.records(db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate))
@router.post(PREFIX + '/assignments/preview')
def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId))
@router.post(PREFIX + '/assignments')
def assign(body: AssignmentBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.assign(db, *ctx, body.bizType, body.id, body.projectId, body.reason, [r.model_dump() for r in body.expectedAssignments]))
@router.get(PREFIX + '/assignments/history')
def history(bizType: str, id: str, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.assignment_history(db, *ctx, bizType, id))