246 lines
11 KiB
Python
246 lines
11 KiB
Python
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,
|
|
)
|