Files
ctms/backend/app/api/v1/fees_contracts.py
T
2026-05-28 10:49:08 +08:00

411 lines
17 KiB
Python

import uuid
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, 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, 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.fee_attachment import FeeAttachmentRead
from app.schemas.user import UserDisplay
from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, endpoint_key: str):
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="不是项目成员")
allowed = await role_has_api_permission(db, project_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, 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="中心已停用")
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(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
q: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, "fees_contracts:read")
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 contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=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,
project_id=contract.project_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_project_access(db, contract.project_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.project_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[FeeAttachmentRead]] = {
"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(
FeeAttachmentRead(
id=attachment.id,
entity_type="contract_fee",
entity_id=attachment.entity_id,
file_type=key,
filename=attachment.filename,
mime_type=attachment.content_type,
size=attachment.file_size,
storage_key=attachment.file_path,
url=None,
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,
project_id=contract.project_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_project_access(db, contract_in.project_id, current_user, "fees_contracts:create")
existing = await contract_fee_crud.get_contract_fee_by_project_center(
db, contract_in.project_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.project_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.project_id,
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail="合同费用已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
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_project_access(db, contract.project_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.project_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.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
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_project_access(db, contract.project_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.project_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
study_id=contract.project_id,
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail="合同费用已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
@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_project_access(db, contract.project_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.project_id,
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
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_project_access(db, contract.project_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.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
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_project_access(db, contract.project_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.project_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)