461 lines
20 KiB
Python
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),
|
|
)
|