import uuid from decimal import Decimal from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission from app.core.project_permissions import role_has_api_permission from app.crud import audit as audit_crud from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee_payment as payment_crud from app.crud import member as member_crud from app.crud import site as site_crud from app.crud import user as user_crud from app.crud import study as study_crud from app.schemas.contract_fee import ContractFeeCreate, ContractFeeDetail, ContractFeeListItem, ContractFeeRead, ContractFeeUpdate from app.schemas.contract_fee_payment import ( ContractFeePaymentCreate, ContractFeePaymentRead, ContractFeePaymentUpdate, ) from app.schemas.fee_common import FeeApiResponse from app.schemas.attachment import AttachmentRead from app.schemas.user import UserDisplay from app.models.attachment import Attachment router = APIRouter() async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str): study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") if is_system_admin(current_user): return None membership = await member_crud.get_member(db, study_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足") return membership async def _ensure_center_active(db: AsyncSession, study_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 != study_id or not site.is_active: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用") def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None: if not value: return None try: return uuid.UUID(str(value)) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) from exc def _resolve_contract_fee_list_query( request: Request, study_id: uuid.UUID | None, center_id: uuid.UUID | None, ) -> tuple[uuid.UUID, uuid.UUID | None]: query = request.query_params resolved_study_id = study_id or _parse_optional_uuid(query.get("projectId"), "项目 ID 格式错误") if not resolved_study_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID") resolved_center_id = center_id or _parse_optional_uuid(query.get("centerId"), "中心 ID 格式错误") return resolved_study_id, resolved_center_id def _to_decimal(value: Any) -> Decimal: if isinstance(value, Decimal): return value if value is None: return Decimal("0") try: return Decimal(str(value)) except Exception: return Decimal("0") def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentUpdate): if data.is_verified is True and data.is_paid is False: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款") if data.is_paid and not data.paid_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期") if data.is_verified and not data.verified_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期") @router.get( "/contracts", response_model=FeeApiResponse[list[ContractFeeListItem]], dependencies=[Depends(get_current_user)], ) async def list_contract_fees( request: Request, study_id: uuid.UUID | None = None, center_id: uuid.UUID | None = None, q: str | None = None, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[list[ContractFeeListItem]]: resolved_study_id, resolved_center_id = _resolve_contract_fee_list_query(request, study_id, center_id) await _ensure_study_access(db, resolved_study_id, current_user, "fees_contracts:read") cra_scope = await get_cra_site_scope(db, resolved_study_id, current_user) center_ids = cra_scope[0] if cra_scope else None if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids: return FeeApiResponse(data=[], meta={"total": 0}) rows = await contract_fee_crud.list_contract_fees( db, resolved_study_id, center_id=resolved_center_id, center_ids=center_ids, q=q, ) items: list[ContractFeeListItem] = [] for contract, center_name, paid_total, verified_total, last_paid_date, last_verified_date in rows: paid_total_decimal = _to_decimal(paid_total) verified_total_decimal = _to_decimal(verified_total) contract_amount_decimal = _to_decimal(contract.contract_amount) unpaid_balance = contract_amount_decimal - paid_total_decimal unverified_balance = paid_total_decimal - verified_total_decimal items.append( ContractFeeListItem( id=contract.id, study_id=contract.study_id, center_id=contract.center_id, contract_no=contract.contract_no, signed_date=contract.signed_date, contract_amount=contract_amount_decimal, currency=contract.currency, remark=contract.remark, contract_cases=contract.contract_cases, actual_cases=contract.actual_cases, settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None, final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None, created_at=contract.created_at, updated_at=contract.updated_at, center_name=center_name or "", paid_total=paid_total_decimal, verified_total=verified_total_decimal, unpaid_balance=unpaid_balance, unverified_balance=unverified_balance, last_paid_date=last_paid_date, last_verified_date=last_verified_date, ) ) return FeeApiResponse(data=items, meta={"total": len(items)}) @router.get( "/contracts/{contract_id}", response_model=FeeApiResponse[ContractFeeDetail], dependencies=[Depends(get_current_user)], ) async def get_contract_fee( contract_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[ContractFeeDetail]: contract = await contract_fee_crud.get_contract_fee(db, contract_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:read") cra_scope = await get_cra_site_scope(db, contract.study_id, current_user) if cra_scope and contract.center_id not in cra_scope[0]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") payments = await payment_crud.list_payments(db, contract.id) attachment_types = ["contract_fee_contract", "contract_fee_voucher", "contract_fee_invoice"] result = await db.execute( select(Attachment).where( Attachment.entity_id == contract.id, Attachment.entity_type.in_(attachment_types), Attachment.is_deleted.is_(False), ) ) attachments = result.scalars().all() user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} users_map = await user_crud.get_users_by_ids(db, user_ids) attachments_map: dict[str, list[AttachmentRead]] = { "contract": [], "voucher": [], "invoice": [], } for attachment in attachments: key = attachment.entity_type.replace("contract_fee_", "", 1) attachments_map.setdefault(key, []) user = users_map.get(attachment.uploaded_by) attachments_map[key].append( AttachmentRead( id=attachment.id, filename=attachment.filename, file_size=attachment.file_size, content_type=attachment.content_type, uploaded_by_id=attachment.uploaded_by, uploaded_by=UserDisplay.model_validate(user) if user else None, uploaded_at=attachment.uploaded_at, ) ) payment_reads = [ContractFeePaymentRead.model_validate(payment) for payment in payments] detail = ContractFeeDetail( id=contract.id, study_id=contract.study_id, center_id=contract.center_id, contract_no=contract.contract_no, signed_date=contract.signed_date, contract_amount=_to_decimal(contract.contract_amount), currency=contract.currency, remark=contract.remark, contract_cases=contract.contract_cases, actual_cases=contract.actual_cases, settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None, final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None, created_at=contract.created_at, updated_at=contract.updated_at, payments=payment_reads, attachments=attachments_map, ) return FeeApiResponse(data=detail) @router.post( "/contracts", response_model=FeeApiResponse[ContractFeeRead], status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def create_contract_fee( contract_in: ContractFeeCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[ContractFeeRead]: await _ensure_study_access(db, contract_in.study_id, current_user, "fees_contracts:create") existing = await contract_fee_crud.get_contract_fee_by_study_center( db, contract_in.study_id, contract_in.center_id ) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用") site = await site_crud.get_site(db, contract_in.center_id) if not site or site.study_id != contract_in.study_id or not site.is_active: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用") contract = await contract_fee_crud.create_contract_fee(db, contract_in) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee", entity_id=contract.id, action="CREATE_CONTRACT_FEE", detail="合同费用已创建", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) @router.patch( "/contracts/{contract_id}", response_model=FeeApiResponse[ContractFeeRead], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def update_contract_fee( contract_id: uuid.UUID, contract_in: ContractFeeUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[ContractFeeRead]: contract = await contract_fee_crud.get_contract_fee(db, contract_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") await _ensure_center_active(db, contract.study_id, contract.center_id) contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee", entity_id=contract_id, action="UPDATE_CONTRACT_FEE", detail="合同费用已更新", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) @router.delete( "/contracts/{contract_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def delete_contract_fee( contract_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: contract = await contract_fee_crud.get_contract_fee(db, contract_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete") await _ensure_center_active(db, contract.study_id, contract.center_id) await contract_fee_crud.delete_contract_fee(db, contract) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee", entity_id=contract_id, action="DELETE_CONTRACT_FEE", detail="合同费用已删除", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) @router.post( "/contracts/{contract_id}/payments", response_model=FeeApiResponse[ContractFeePaymentRead], status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def create_contract_payment( contract_id: uuid.UUID, payment_in: ContractFeePaymentCreate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[ContractFeePaymentRead]: contract = await contract_fee_crud.get_contract_fee(db, contract_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") _validate_payment_rules(payment_in) payment = await payment_crud.create_payment(db, contract_id, payment_in) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee_payment", entity_id=payment.id, action="CREATE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已创建", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) @router.patch( "/payments/{payment_id}", response_model=FeeApiResponse[ContractFeePaymentRead], dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def update_contract_payment( payment_id: uuid.UUID, payment_in: ContractFeePaymentUpdate, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> FeeApiResponse[ContractFeePaymentRead]: payment = await payment_crud.get_payment(db, payment_id) if not payment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在") contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") merged_payment = ContractFeePaymentCreate( amount=payment_in.amount if payment_in.amount is not None else payment.amount, paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date, verified_date=payment_in.verified_date if payment_in.verified_date is not None else payment.verified_date, is_paid=payment_in.is_paid if payment_in.is_paid is not None else payment.is_paid, is_verified=payment_in.is_verified if payment_in.is_verified is not None else payment.is_verified, remark=payment_in.remark if payment_in.remark is not None else payment.remark, ) _validate_payment_rules(merged_payment) payment = await payment_crud.update_payment(db, payment, payment_in) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee_payment", entity_id=payment_id, action="UPDATE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已更新", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), ) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) @router.delete( "/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_current_user), Depends(require_study_not_locked())], ) async def delete_contract_payment( payment_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: payment = await payment_crud.get_payment(db, payment_id) if not payment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在") contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) if not contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update") await payment_crud.delete_payment(db, payment) await payment_crud.resequence_payments(db, contract.id) await audit_crud.log_action( db, study_id=contract.study_id, entity_type="contract_fee_payment", entity_id=payment_id, action="DELETE_CONTRACT_FEE_PAYMENT", detail="合同费用分期已删除", operator_id=current_user.id, operator_role=await get_operator_role_label(db, contract.study_id, current_user), )