费用管理内容优化-初步

This commit is contained in:
Cheng Zhou
2026-01-13 16:49:55 +08:00
parent 0c7c03069a
commit 1db36f40b0
43 changed files with 5229 additions and 26 deletions
+2
View File
@@ -137,6 +137,7 @@ async def download_attachment(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
@@ -181,6 +182,7 @@ async def global_download_attachment(
path=attachment.file_path,
filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
+255
View File
@@ -0,0 +1,255 @@
import os
import uuid
from pathlib import Path
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.core.security import decode_token
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 fee_attachment as fee_attachment_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import user as user_crud
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.fee_common import FeeApiResponse
from app.schemas.user import UserDisplay
router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
"special_expense": {"voucher", "invoice", "other"},
}
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
if entity_type == "contract_fee":
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
if entity_type == "contract_payment":
payment = await payment_crud.get_payment(db, entity_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="合同费用不存在")
return contract.project_id
if entity_type == "special_expense":
expense = await special_crud.get_special_expense(db, entity_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
return expense.project_id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
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
async def _authorize_download_user(request: Request, db: AsyncSession):
token = None
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1]
if not token:
token = request.query_params.get("token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
payload = decode_token(token)
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
return user
@router.post(
"/attachments",
response_model=FeeApiResponse[FeeAttachmentRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user)],
)
async def upload_fee_attachment(
entity_type: str = Form(...),
entity_id: uuid.UUID = Form(...),
file_type: str = Form(...),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[FeeAttachmentRead]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=True)
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
dest_dir.mkdir(parents=True, exist_ok=True)
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
dest_path = dest_dir / unique_name
content = await file.read()
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
attachment = await fee_attachment_crud.create_attachment(
db,
entity_type=entity_type,
entity_id=entity_id,
file_type=file_type,
filename=file.filename,
mime_type=file.content_type,
size=len(content),
storage_key=str(dest_path),
url=None,
uploaded_by=current_user.id,
)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=entity_type,
entity_id=entity_id,
action="UPLOAD_FEE_ATTACHMENT",
detail=f"附件已上传:{file.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(
data=FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(current_user),
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
@router.get(
"/attachments",
response_model=FeeApiResponse[list[FeeAttachmentRead]],
dependencies=[Depends(get_current_user)],
)
async def list_fee_attachments(
entity_type: str,
entity_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[FeeAttachmentRead]]:
if entity_type not in ALLOWED_ENTITY_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
project_id = await _resolve_project_id(db, entity_type, entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
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)
items: list[FeeAttachmentRead] = []
for attachment in attachments:
user = users_map.get(attachment.uploaded_by)
items.append(
FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_by_id=attachment.uploaded_by,
uploaded_at=attachment.uploaded_at,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/attachments/{attachment_id}/download",
response_class=FileResponse,
)
async def download_fee_attachment(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
):
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
current_user = await _authorize_download_user(request, db)
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
await _ensure_project_access(db, project_id, current_user, write=False)
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
return FileResponse(
path=attachment.storage_key,
filename=attachment.filename,
media_type=attachment.mime_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
)
@router.delete(
"/attachments/{attachment_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user)],
)
async def delete_fee_attachment(
attachment_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
membership = await _ensure_project_access(db, project_id, current_user, write=True)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
can_delete = (
role_value == "ADMIN"
or attachment.uploaded_by == current_user.id
or (membership and getattr(membership, "role_in_study", None) == "PM")
)
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await fee_attachment_crud.soft_delete_attachment(db, attachment)
await audit_crud.log_action(
db,
study_id=project_id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
action="DELETE_FEE_ATTACHMENT",
detail=f"附件已删除:{attachment.filename}",
operator_id=current_user.id,
operator_role=current_user.role,
)
+368
View File
@@ -0,0 +1,368 @@
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 app.core.deps import get_current_user, get_db_session
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 fee_attachment as fee_attachment_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
router = APIRouter()
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 _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, write=False)
rows = await contract_fee_crud.list_contract_fees(db, project_id, center_id=center_id, 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_amount=contract_amount_decimal,
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, write=False)
payments = await payment_crud.list_payments(db, contract.id)
attachments = await fee_attachment_crud.list_attachments(db, entity_type="contract_fee", entity_id=contract.id)
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.file_type
attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by)
attachments_map[key].append(
FeeAttachmentRead(
id=attachment.id,
entity_type=attachment.entity_type,
entity_id=attachment.entity_id,
file_type=attachment.file_type,
filename=attachment.filename,
mime_type=attachment.mime_type,
size=attachment.size,
storage_key=attachment.storage_key,
url=attachment.url,
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_amount=_to_decimal(contract.contract_amount),
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)],
)
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, write=True)
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:
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)],
)
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, write=True)
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)],
)
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, write=True)
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)],
)
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, write=True)
_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)],
)
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, write=True)
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)],
)
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, write=True)
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,
)
+224
View File
@@ -0,0 +1,224 @@
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_current_user, get_db_session
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="费用类别无效")
@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)
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
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)
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)],
)
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
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)],
)
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目")
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)],
)
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 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,
)
+4 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -19,6 +19,9 @@ api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["a
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])