import uuid from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked from app.crud import audit as audit_crud from app.crud import member as member_crud from app.crud import special_expense as special_crud from app.crud import study as study_crud from app.crud import site as site_crud from app.schemas.fee_common import FeeApiResponse from app.schemas.special_expense import ( SpecialExpenseCreate, SpecialExpenseListItem, SpecialExpenseRead, SpecialExpenseUpdate, ) router = APIRouter() ALLOWED_CATEGORIES = {"travel", "meal", "meeting", "supplies", "other"} async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False): study = await study_crud.get(db, project_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if role_value == "ADMIN": return None membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") if write and membership.role_in_study != "PM": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") return membership def _validate_special_rules(payload: SpecialExpenseCreate | SpecialExpenseUpdate): if payload.is_verified is True and payload.is_paid is False: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款") if payload.is_paid and not payload.paid_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期") if payload.is_verified and not payload.verified_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期") def _validate_category(value: str | None): if value and value not in ALLOWED_CATEGORIES: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效") async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None): if not center_id: return site = await site_crud.get_site(db, center_id) if not site or site.study_id != project_id or not site.is_active: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") @router.get( "/special", response_model=FeeApiResponse[list[SpecialExpenseListItem]], dependencies=[Depends(get_current_user)], ) async def list_special_expenses( project_id: uuid.UUID = Query(..., alias="projectId"), center_id: uuid.UUID | None = Query(None, alias="centerId"), category: str | None = None, date_from: date | None = Query(None, alias="dateFrom"), date_to: date | None = Query(None, alias="dateTo"), db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[list[SpecialExpenseListItem]]: await _ensure_project_access(db, project_id, current_user, write=False) _validate_category(category) cra_scope = await get_cra_site_scope(db, project_id, current_user) center_ids = cra_scope[0] if cra_scope else None if center_id and center_ids is not None and center_id not in center_ids: return FeeApiResponse(data=[], meta={"total": 0}) rows = await special_crud.list_special_expenses( db, project_id, center_id=center_id, center_ids=center_ids, category=category, date_from=date_from, date_to=date_to, ) items: list[SpecialExpenseListItem] = [] for expense, center_name, attachments_count in rows: items.append( SpecialExpenseListItem( id=expense.id, project_id=expense.project_id, center_id=expense.center_id, category=expense.category, amount=expense.amount, happen_date=expense.happen_date, description=expense.description, is_paid=expense.is_paid, paid_date=expense.paid_date, is_verified=expense.is_verified, verified_date=expense.verified_date, created_by=expense.created_by, created_at=expense.created_at, updated_at=expense.updated_at, center_name=center_name, attachments_count=attachments_count or 0, ) ) return FeeApiResponse(data=items, meta={"total": len(items)}) @router.get( "/special/{expense_id}", response_model=FeeApiResponse[SpecialExpenseRead], dependencies=[Depends(get_current_user)], ) async def get_special_expense( expense_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[SpecialExpenseRead]: expense = await special_crud.get_special_expense(db, expense_id) if not expense: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在") await _ensure_project_access(db, expense.project_id, current_user, write=False) cra_scope = await get_cra_site_scope(db, expense.project_id, current_user) if cra_scope and expense.center_id not in cra_scope[0]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense)) @router.post( "/special", response_model=FeeApiResponse[SpecialExpenseRead], status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def create_special_expense( expense_in: SpecialExpenseCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[SpecialExpenseRead]: await _ensure_project_access(db, expense_in.project_id, current_user, write=True) _validate_category(expense_in.category) _validate_special_rules(expense_in) if expense_in.center_id: site = await site_crud.get_site(db, expense_in.center_id) if not site or site.study_id != expense_in.project_id or not site.is_active: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用") await _ensure_center_active(db, expense_in.project_id, expense_in.center_id) expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id) await audit_crud.log_action( db, study_id=expense.project_id, entity_type="special_expense", entity_id=expense.id, action="CREATE_SPECIAL_EXPENSE", detail="特殊费用已创建", operator_id=current_user.id, operator_role=current_user.role, ) return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense)) @router.patch( "/special/{expense_id}", response_model=FeeApiResponse[SpecialExpenseRead], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def update_special_expense( expense_id: uuid.UUID, expense_in: SpecialExpenseUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[SpecialExpenseRead]: expense = await special_crud.get_special_expense(db, expense_id) if not expense: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在") await _ensure_project_access(db, expense.project_id, current_user, write=True) _validate_category(expense_in.category) merged = SpecialExpenseCreate( project_id=expense.project_id, center_id=expense_in.center_id if expense_in.center_id is not None else expense.center_id, category=expense_in.category if expense_in.category is not None else expense.category, amount=expense_in.amount if expense_in.amount is not None else expense.amount, happen_date=expense_in.happen_date if expense_in.happen_date is not None else expense.happen_date, description=expense_in.description if expense_in.description is not None else expense.description, is_paid=expense_in.is_paid if expense_in.is_paid is not None else expense.is_paid, paid_date=expense_in.paid_date if expense_in.paid_date is not None else expense.paid_date, is_verified=expense_in.is_verified if expense_in.is_verified is not None else expense.is_verified, verified_date=expense_in.verified_date if expense_in.verified_date is not None else expense.verified_date, ) _validate_special_rules(merged) if expense_in.center_id: site = await site_crud.get_site(db, expense_in.center_id) if not site or site.study_id != expense.project_id or not site.is_active: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用") await _ensure_center_active(db, expense.project_id, expense_in.center_id) else: await _ensure_center_active(db, expense.project_id, expense.center_id) expense = await special_crud.update_special_expense(db, expense, expense_in) await audit_crud.log_action( db, study_id=expense.project_id, entity_type="special_expense", entity_id=expense_id, action="UPDATE_SPECIAL_EXPENSE", detail="特殊费用已更新", operator_id=current_user.id, operator_role=current_user.role, ) return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense)) @router.delete( "/special/{expense_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def delete_special_expense( expense_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: expense = await special_crud.get_special_expense(db, expense_id) if not expense: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在") await _ensure_project_access(db, expense.project_id, current_user, write=True) await _ensure_center_active(db, expense.project_id, expense.center_id) await special_crud.delete_special_expense(db, expense) await audit_crud.log_action( db, study_id=expense.project_id, entity_type="special_expense", entity_id=expense_id, action="DELETE_SPECIAL_EXPENSE", detail="特殊费用已删除", operator_id=current_user.id, operator_role=current_user.role, )