Files
ctms/backend/app/api/v1/fees_contracts.py
T
2026-06-10 10:20:04 +08:00

461 lines
20 KiB
Python

import uuid
import json
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="中心已停用")
async def _contract_audit_name(db: AsyncSession, contract) -> str:
site = await site_crud.get_site(db, contract.center_id)
center_name = site.name if site else "中心"
contract_no = str(contract.contract_no or "").strip()
return f"{center_name} / {contract_no}" if contract_no else center_name
async def _contract_audit_detail(db: AsyncSession, action: str, contract) -> str:
name = await _contract_audit_name(db, contract)
return json.dumps({"targetName": name, "description": f"{action}合同费用“{name}”"}, ensure_ascii=False)
async def _payment_audit_name(db: AsyncSession, contract, payment) -> str:
contract_name = await _contract_audit_name(db, contract)
seq = getattr(payment, "seq", None)
amount = getattr(payment, "amount", None)
if seq:
return f"{contract_name} / 第{seq}期"
if amount is not None:
return f"{contract_name} / {amount}"
return contract_name
async def _payment_audit_detail(db: AsyncSession, action: str, contract, payment) -> str:
name = await _payment_audit_name(db, contract, payment)
return json.dumps({"targetName": name, "description": f"{action}合同费用分期“{name}”"}, ensure_ascii=False)
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=await _contract_audit_detail(db, "创建", contract),
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=await _contract_audit_detail(db, "更新", contract),
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)
contract_detail = await _contract_audit_detail(db, "删除", contract)
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=contract_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=await _payment_audit_detail(db, "创建", contract, payment),
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=await _payment_audit_detail(db, "更新", contract, payment),
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")
payment_detail = await _payment_audit_detail(db, "删除", contract, payment)
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=payment_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)