费用管理内容优化-初步
This commit is contained in:
@@ -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}"'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.models.contract_fee_payment import ContractFeePayment
|
||||
from app.models.site import Site
|
||||
from app.schemas.contract_fee import ContractFeeCreate, ContractFeeUpdate
|
||||
|
||||
|
||||
async def create_contract_fee(
|
||||
db: AsyncSession,
|
||||
contract_in: ContractFeeCreate,
|
||||
) -> ContractFee:
|
||||
contract = ContractFee(
|
||||
project_id=contract_in.project_id,
|
||||
center_id=contract_in.center_id,
|
||||
contract_amount=contract_in.contract_amount,
|
||||
contract_cases=contract_in.contract_cases,
|
||||
actual_cases=contract_in.actual_cases,
|
||||
settlement_amount=contract_in.settlement_amount,
|
||||
final_payment_amount=contract_in.final_payment_amount,
|
||||
)
|
||||
db.add(contract)
|
||||
await db.commit()
|
||||
await db.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def get_contract_fee(db: AsyncSession, contract_id: uuid.UUID) -> ContractFee | None:
|
||||
result = await db.execute(select(ContractFee).where(ContractFee.id == contract_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_contract_fee_by_project_center(
|
||||
db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID
|
||||
) -> ContractFee | None:
|
||||
result = await db.execute(
|
||||
select(ContractFee).where(ContractFee.project_id == project_id, ContractFee.center_id == center_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_contract_fees(
|
||||
db: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
center_id: uuid.UUID | None = None,
|
||||
q: str | None = None,
|
||||
) -> Sequence[tuple[ContractFee, str, float, float, date | None, date | None]]:
|
||||
paid_total = func.coalesce(
|
||||
func.sum(case((ContractFeePayment.is_paid.is_(True), ContractFeePayment.amount), else_=0)),
|
||||
0,
|
||||
).label("paid_total")
|
||||
verified_total = func.coalesce(
|
||||
func.sum(case((ContractFeePayment.is_verified.is_(True), ContractFeePayment.amount), else_=0)),
|
||||
0,
|
||||
).label("verified_total")
|
||||
last_paid_date = func.max(
|
||||
case((ContractFeePayment.is_paid.is_(True), ContractFeePayment.paid_date), else_=None)
|
||||
).label("last_paid_date")
|
||||
last_verified_date = func.max(
|
||||
case((ContractFeePayment.is_verified.is_(True), ContractFeePayment.verified_date), else_=None)
|
||||
).label("last_verified_date")
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ContractFee,
|
||||
Site.name.label("center_name"),
|
||||
paid_total,
|
||||
verified_total,
|
||||
last_paid_date,
|
||||
last_verified_date,
|
||||
)
|
||||
.join(Site, Site.id == ContractFee.center_id)
|
||||
.outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id)
|
||||
.where(ContractFee.project_id == project_id)
|
||||
.group_by(ContractFee.id, Site.name)
|
||||
)
|
||||
|
||||
if center_id:
|
||||
stmt = stmt.where(ContractFee.center_id == center_id)
|
||||
|
||||
if q:
|
||||
conditions = [Site.name.ilike(f"%{q}%")]
|
||||
try:
|
||||
conditions.append(Site.id == uuid.UUID(q))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
stmt = stmt.where(or_(*conditions))
|
||||
|
||||
stmt = stmt.order_by(Site.name.asc())
|
||||
result = await db.execute(stmt)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def update_contract_fee(
|
||||
db: AsyncSession, contract: ContractFee, contract_in: ContractFeeUpdate
|
||||
) -> ContractFee:
|
||||
update_data = contract_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(contract, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def delete_contract_fee(db: AsyncSession, contract: ContractFee) -> None:
|
||||
await db.delete(contract)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,74 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contract_fee_payment import ContractFeePayment
|
||||
from app.schemas.contract_fee_payment import ContractFeePaymentCreate, ContractFeePaymentUpdate
|
||||
|
||||
|
||||
async def create_payment(
|
||||
db: AsyncSession,
|
||||
contract_fee_id: uuid.UUID,
|
||||
payment_in: ContractFeePaymentCreate,
|
||||
) -> ContractFeePayment:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.max(ContractFeePayment.seq), 0)).where(
|
||||
ContractFeePayment.contract_fee_id == contract_fee_id
|
||||
)
|
||||
)
|
||||
next_seq = (result.scalar_one_or_none() or 0) + 1
|
||||
payment = ContractFeePayment(
|
||||
contract_fee_id=contract_fee_id,
|
||||
seq=next_seq,
|
||||
amount=payment_in.amount,
|
||||
paid_date=payment_in.paid_date,
|
||||
verified_date=payment_in.verified_date,
|
||||
is_paid=payment_in.is_paid,
|
||||
is_verified=payment_in.is_verified,
|
||||
remark=payment_in.remark,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payment(db: AsyncSession, payment_id: uuid.UUID) -> ContractFeePayment | None:
|
||||
result = await db.execute(select(ContractFeePayment).where(ContractFeePayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_payments(db: AsyncSession, contract_fee_id: uuid.UUID) -> Sequence[ContractFeePayment]:
|
||||
result = await db.execute(
|
||||
select(ContractFeePayment)
|
||||
.where(ContractFeePayment.contract_fee_id == contract_fee_id)
|
||||
.order_by(ContractFeePayment.seq.asc(), ContractFeePayment.created_at.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_payment(
|
||||
db: AsyncSession, payment: ContractFeePayment, payment_in: ContractFeePaymentUpdate
|
||||
) -> ContractFeePayment:
|
||||
update_data = payment_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(payment, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def delete_payment(db: AsyncSession, payment: ContractFeePayment) -> None:
|
||||
await db.delete(payment)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def resequence_payments(db: AsyncSession, contract_fee_id: uuid.UUID) -> None:
|
||||
payments = await list_payments(db, contract_fee_id)
|
||||
for idx, payment in enumerate(payments, start=1):
|
||||
if payment.seq != idx:
|
||||
payment.seq = idx
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,70 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fee_attachment import FeeAttachment
|
||||
|
||||
|
||||
async def create_attachment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
file_type: str,
|
||||
filename: str,
|
||||
mime_type: str | None,
|
||||
size: int,
|
||||
storage_key: str | None,
|
||||
url: str | None,
|
||||
uploaded_by: uuid.UUID,
|
||||
) -> FeeAttachment:
|
||||
attachment = FeeAttachment(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
file_type=file_type,
|
||||
filename=filename,
|
||||
mime_type=mime_type,
|
||||
size=size,
|
||||
storage_key=storage_key,
|
||||
url=url,
|
||||
uploaded_by=uploaded_by,
|
||||
)
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment)
|
||||
return attachment
|
||||
|
||||
|
||||
async def list_attachments(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> Sequence[FeeAttachment]:
|
||||
result = await db.execute(
|
||||
select(FeeAttachment)
|
||||
.where(
|
||||
FeeAttachment.entity_type == entity_type,
|
||||
FeeAttachment.entity_id == entity_id,
|
||||
FeeAttachment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(FeeAttachment.uploaded_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> FeeAttachment | None:
|
||||
result = await db.execute(
|
||||
select(FeeAttachment).where(FeeAttachment.id == attachment_id, FeeAttachment.is_deleted.is_(False))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def soft_delete_attachment(db: AsyncSession, attachment: FeeAttachment) -> FeeAttachment:
|
||||
attachment.is_deleted = True
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
await db.refresh(attachment)
|
||||
return attachment
|
||||
@@ -0,0 +1,98 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.fee_attachment import FeeAttachment
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.site import Site
|
||||
from app.schemas.special_expense import SpecialExpenseCreate, SpecialExpenseUpdate
|
||||
|
||||
|
||||
async def create_special_expense(
|
||||
db: AsyncSession,
|
||||
expense_in: SpecialExpenseCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> SpecialExpense:
|
||||
expense = SpecialExpense(
|
||||
project_id=expense_in.project_id,
|
||||
center_id=expense_in.center_id,
|
||||
category=expense_in.category,
|
||||
amount=expense_in.amount,
|
||||
happen_date=expense_in.happen_date,
|
||||
description=expense_in.description,
|
||||
is_paid=expense_in.is_paid,
|
||||
paid_date=expense_in.paid_date,
|
||||
is_verified=expense_in.is_verified,
|
||||
verified_date=expense_in.verified_date,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(expense)
|
||||
await db.commit()
|
||||
await db.refresh(expense)
|
||||
return expense
|
||||
|
||||
|
||||
async def get_special_expense(db: AsyncSession, expense_id: uuid.UUID) -> SpecialExpense | None:
|
||||
result = await db.execute(select(SpecialExpense).where(SpecialExpense.id == expense_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_special_expenses(
|
||||
db: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
center_id: uuid.UUID | None = None,
|
||||
category: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
) -> Sequence[tuple[SpecialExpense, str | None, int]]:
|
||||
attachment_count = func.count(FeeAttachment.id).label("attachments_count")
|
||||
stmt = (
|
||||
select(
|
||||
SpecialExpense,
|
||||
Site.name.label("center_name"),
|
||||
attachment_count,
|
||||
)
|
||||
.outerjoin(Site, Site.id == SpecialExpense.center_id)
|
||||
.outerjoin(
|
||||
FeeAttachment,
|
||||
and_(
|
||||
FeeAttachment.entity_type == "special_expense",
|
||||
FeeAttachment.entity_id == SpecialExpense.id,
|
||||
FeeAttachment.is_deleted.is_(False),
|
||||
),
|
||||
)
|
||||
.where(SpecialExpense.project_id == project_id)
|
||||
.group_by(SpecialExpense.id, Site.name)
|
||||
.order_by(SpecialExpense.happen_date.desc().nullslast(), SpecialExpense.created_at.desc())
|
||||
)
|
||||
|
||||
if center_id:
|
||||
stmt = stmt.where(SpecialExpense.center_id == center_id)
|
||||
if category:
|
||||
stmt = stmt.where(SpecialExpense.category == category)
|
||||
if date_from:
|
||||
stmt = stmt.where(SpecialExpense.happen_date >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(SpecialExpense.happen_date <= date_to)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def update_special_expense(
|
||||
db: AsyncSession, expense: SpecialExpense, expense_in: SpecialExpenseUpdate
|
||||
) -> SpecialExpense:
|
||||
update_data = expense_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(expense, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(expense)
|
||||
return expense
|
||||
|
||||
|
||||
async def delete_special_expense(db: AsyncSession, expense: SpecialExpense) -> None:
|
||||
await db.delete(expense)
|
||||
await db.commit()
|
||||
@@ -14,6 +14,10 @@ from app.models.ae import AdverseEvent # noqa: F401
|
||||
from app.models.finance import FinanceItem # noqa: F401
|
||||
from app.models.finance_contract import FinanceContract # noqa: F401
|
||||
from app.models.finance_special import FinanceSpecial # noqa: F401
|
||||
from app.models.contract_fee import ContractFee # noqa: F401
|
||||
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
|
||||
from app.models.special_expense import SpecialExpense # noqa: F401
|
||||
from app.models.fee_attachment import FeeAttachment # noqa: F401
|
||||
from app.models.drug_shipment import DrugShipment # noqa: F401
|
||||
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
|
||||
from app.models.startup_ethics import StartupEthics # noqa: F401
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.exceptions import register_exception_handlers
|
||||
from app.crud.user import ensure_admin_exists
|
||||
from app.db.base import Base
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.services.fee_seed import seed_demo_fees
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -23,6 +24,7 @@ async def lifespan(_: FastAPI):
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with SessionLocal() as session:
|
||||
await ensure_admin_exists(session)
|
||||
await seed_demo_fees(session)
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ContractFee(Base):
|
||||
__tablename__ = "contract_fees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "center_id", name="uq_contract_fees_project_center"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
center_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
||||
contract_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
contract_cases: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
actual_cases: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
settlement_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
final_payment_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ContractFeePayment(Base):
|
||||
__tablename__ = "contract_fee_payments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
contract_fee_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("contract_fees.id"), index=True, nullable=False
|
||||
)
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
paid_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
verified_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class FeeAttachment(Base):
|
||||
__tablename__ = "fee_attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
file_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
size: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
storage_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class SpecialExpense(Base):
|
||||
__tablename__ = "special_expenses"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
center_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
happen_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
paid_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
verified_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.contract_fee_payment import ContractFeePaymentRead
|
||||
from app.schemas.fee_attachment import FeeAttachmentRead
|
||||
|
||||
|
||||
class ContractFeeCreate(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
center_id: uuid.UUID
|
||||
contract_amount: Decimal = Field(ge=0)
|
||||
contract_cases: int = Field(ge=0)
|
||||
actual_cases: Optional[int] = Field(default=None, ge=0)
|
||||
settlement_amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
final_payment_amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ContractFeeUpdate(BaseModel):
|
||||
contract_amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
contract_cases: Optional[int] = Field(default=None, ge=0)
|
||||
actual_cases: Optional[int] = Field(default=None, ge=0)
|
||||
settlement_amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
final_payment_amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ContractFeeRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
center_id: uuid.UUID
|
||||
contract_amount: Decimal
|
||||
contract_cases: int
|
||||
actual_cases: Optional[int]
|
||||
settlement_amount: Optional[Decimal]
|
||||
final_payment_amount: Optional[Decimal]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ContractFeeListItem(ContractFeeRead):
|
||||
center_name: str
|
||||
paid_total: Decimal
|
||||
verified_total: Decimal
|
||||
unpaid_balance: Decimal
|
||||
unverified_balance: Decimal
|
||||
last_paid_date: Optional[date]
|
||||
last_verified_date: Optional[date]
|
||||
|
||||
|
||||
class ContractFeeDetail(ContractFeeRead):
|
||||
payments: list[ContractFeePaymentRead]
|
||||
attachments: dict[str, list[FeeAttachmentRead]]
|
||||
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ContractFeePaymentCreate(BaseModel):
|
||||
amount: Decimal = Field(ge=0)
|
||||
paid_date: Optional[date] = None
|
||||
verified_date: Optional[date] = None
|
||||
is_paid: bool = False
|
||||
is_verified: bool = False
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ContractFeePaymentUpdate(BaseModel):
|
||||
amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
paid_date: Optional[date] = None
|
||||
verified_date: Optional[date] = None
|
||||
is_paid: Optional[bool] = None
|
||||
is_verified: Optional[bool] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ContractFeePaymentRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
contract_fee_id: uuid.UUID
|
||||
seq: int
|
||||
amount: Decimal
|
||||
paid_date: Optional[date]
|
||||
verified_date: Optional[date]
|
||||
is_paid: bool
|
||||
is_verified: bool
|
||||
remark: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.schemas.user import UserDisplay
|
||||
|
||||
|
||||
class FeeAttachmentRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
entity_type: str
|
||||
entity_id: uuid.UUID
|
||||
file_type: str
|
||||
filename: str
|
||||
mime_type: Optional[str]
|
||||
size: int
|
||||
storage_key: Optional[str]
|
||||
url: Optional[str]
|
||||
uploaded_by: Optional[UserDisplay]
|
||||
uploaded_by_id: Optional[uuid.UUID] = None
|
||||
uploaded_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic.generics import GenericModel
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class FeeApiResponse(GenericModel, Generic[T]):
|
||||
data: T | None = None
|
||||
error: str | dict | None = None
|
||||
meta: dict | None = None
|
||||
@@ -0,0 +1,55 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SpecialExpenseCreate(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
center_id: Optional[uuid.UUID] = None
|
||||
category: str
|
||||
amount: Decimal = Field(ge=0)
|
||||
happen_date: Optional[date] = None
|
||||
description: Optional[str] = None
|
||||
is_paid: bool = False
|
||||
paid_date: Optional[date] = None
|
||||
is_verified: bool = False
|
||||
verified_date: Optional[date] = None
|
||||
|
||||
|
||||
class SpecialExpenseUpdate(BaseModel):
|
||||
center_id: Optional[uuid.UUID] = None
|
||||
category: Optional[str] = None
|
||||
amount: Optional[Decimal] = Field(default=None, ge=0)
|
||||
happen_date: Optional[date] = None
|
||||
description: Optional[str] = None
|
||||
is_paid: Optional[bool] = None
|
||||
paid_date: Optional[date] = None
|
||||
is_verified: Optional[bool] = None
|
||||
verified_date: Optional[date] = None
|
||||
|
||||
|
||||
class SpecialExpenseRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
center_id: Optional[uuid.UUID]
|
||||
category: str
|
||||
amount: Decimal
|
||||
happen_date: Optional[date]
|
||||
description: Optional[str]
|
||||
is_paid: bool
|
||||
paid_date: Optional[date]
|
||||
is_verified: bool
|
||||
verified_date: Optional[date]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SpecialExpenseListItem(SpecialExpenseRead):
|
||||
center_name: Optional[str]
|
||||
attachments_count: int
|
||||
@@ -0,0 +1,175 @@
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.contract_fee import ContractFee
|
||||
from app.models.contract_fee_payment import ContractFeePayment
|
||||
from app.models.fee_attachment import FeeAttachment
|
||||
from app.models.site import Site
|
||||
from app.models.special_expense import SpecialExpense
|
||||
from app.models.study import Study
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def seed_demo_fees(db: AsyncSession) -> None:
|
||||
existing_contract = await db.execute(select(ContractFee.id).limit(1))
|
||||
existing_special = await db.execute(select(SpecialExpense.id).limit(1))
|
||||
if existing_contract.scalar_one_or_none() or existing_special.scalar_one_or_none():
|
||||
return
|
||||
|
||||
result = await db.execute(select(Study).order_by(Study.created_at.asc()))
|
||||
study = result.scalars().first()
|
||||
if not study:
|
||||
study = Study(
|
||||
id=uuid.uuid4(),
|
||||
code="DEMO-CTMS",
|
||||
name="示例临床试验项目",
|
||||
sponsor="示例申办方",
|
||||
status="ACTIVE",
|
||||
)
|
||||
db.add(study)
|
||||
await db.commit()
|
||||
await db.refresh(study)
|
||||
|
||||
result = await db.execute(select(Site).where(Site.study_id == study.id))
|
||||
sites = result.scalars().all()
|
||||
if len(sites) < 3:
|
||||
for idx in range(3 - len(sites)):
|
||||
site = Site(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study.id,
|
||||
name=f"示例中心 {len(sites) + idx + 1}",
|
||||
city="上海",
|
||||
pi_name="张医生",
|
||||
contact="010-88888888",
|
||||
)
|
||||
db.add(site)
|
||||
await db.commit()
|
||||
result = await db.execute(select(Site).where(Site.study_id == study.id))
|
||||
sites = result.scalars().all()
|
||||
|
||||
user_result = await db.execute(select(User).order_by(User.created_at.asc()))
|
||||
uploader = user_result.scalars().first()
|
||||
uploader_id = uploader.id if uploader else None
|
||||
|
||||
today = date.today()
|
||||
for idx, site in enumerate(sites[:3], start=1):
|
||||
contract = ContractFee(
|
||||
id=uuid.uuid4(),
|
||||
project_id=study.id,
|
||||
center_id=site.id,
|
||||
contract_amount=Decimal("120000.00") + Decimal(idx * 10000),
|
||||
contract_cases=50 + idx * 5,
|
||||
actual_cases=40 + idx * 4,
|
||||
settlement_amount=Decimal("80000.00") + Decimal(idx * 5000),
|
||||
final_payment_amount=Decimal("20000.00") + Decimal(idx * 2000),
|
||||
)
|
||||
db.add(contract)
|
||||
await db.commit()
|
||||
await db.refresh(contract)
|
||||
|
||||
payments = [
|
||||
ContractFeePayment(
|
||||
id=uuid.uuid4(),
|
||||
contract_fee_id=contract.id,
|
||||
seq=1,
|
||||
amount=Decimal("40000.00") + Decimal(idx * 2000),
|
||||
is_paid=True,
|
||||
paid_date=today - timedelta(days=30),
|
||||
is_verified=True,
|
||||
verified_date=today - timedelta(days=20),
|
||||
remark="首付款已核销",
|
||||
),
|
||||
ContractFeePayment(
|
||||
id=uuid.uuid4(),
|
||||
contract_fee_id=contract.id,
|
||||
seq=2,
|
||||
amount=Decimal("30000.00") + Decimal(idx * 1000),
|
||||
is_paid=True,
|
||||
paid_date=today - timedelta(days=10),
|
||||
is_verified=False,
|
||||
remark="等待核销",
|
||||
),
|
||||
ContractFeePayment(
|
||||
id=uuid.uuid4(),
|
||||
contract_fee_id=contract.id,
|
||||
seq=3,
|
||||
amount=Decimal("20000.00") + Decimal(idx * 1000),
|
||||
is_paid=False,
|
||||
remark="未打款",
|
||||
),
|
||||
]
|
||||
db.add_all(payments)
|
||||
await db.commit()
|
||||
|
||||
if uploader_id:
|
||||
attachments = [
|
||||
FeeAttachment(
|
||||
id=uuid.uuid4(),
|
||||
entity_type="contract_fee",
|
||||
entity_id=contract.id,
|
||||
file_type="contract",
|
||||
filename=f"合同-{site.name}.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=102400,
|
||||
storage_key=None,
|
||||
url="https://example.com/contract-demo.pdf",
|
||||
uploaded_by=uploader_id,
|
||||
),
|
||||
FeeAttachment(
|
||||
id=uuid.uuid4(),
|
||||
entity_type="contract_fee",
|
||||
entity_id=contract.id,
|
||||
file_type="voucher",
|
||||
filename=f"凭证-{site.name}.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=20480,
|
||||
storage_key=None,
|
||||
url="https://example.com/voucher-demo.pdf",
|
||||
uploaded_by=uploader_id,
|
||||
),
|
||||
]
|
||||
db.add_all(attachments)
|
||||
await db.commit()
|
||||
|
||||
categories = ["travel", "meal", "meeting", "supplies", "other"]
|
||||
for idx, category in enumerate(categories):
|
||||
for seq in range(2):
|
||||
expense = SpecialExpense(
|
||||
id=uuid.uuid4(),
|
||||
project_id=study.id,
|
||||
center_id=sites[seq % len(sites)].id if sites else None,
|
||||
category=category,
|
||||
amount=Decimal("800.00") + Decimal(seq * 150),
|
||||
happen_date=today - timedelta(days=5 * (seq + 1)),
|
||||
description=f"{category} 费用示例",
|
||||
is_paid=seq % 2 == 0,
|
||||
paid_date=today - timedelta(days=3 * (seq + 1)) if seq % 2 == 0 else None,
|
||||
is_verified=seq == 0,
|
||||
verified_date=today - timedelta(days=2 * (seq + 1)) if seq == 0 else None,
|
||||
created_by=uploader_id,
|
||||
)
|
||||
db.add(expense)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(select(SpecialExpense).where(SpecialExpense.project_id == study.id))
|
||||
expenses = result.scalars().all()
|
||||
if uploader_id:
|
||||
for expense in expenses[:3]:
|
||||
attachment = FeeAttachment(
|
||||
id=uuid.uuid4(),
|
||||
entity_type="special_expense",
|
||||
entity_id=expense.id,
|
||||
file_type="invoice",
|
||||
filename=f"发票-{expense.id}.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=40960,
|
||||
storage_key=None,
|
||||
url="https://example.com/invoice-demo.pdf",
|
||||
uploaded_by=uploader_id,
|
||||
)
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
Reference in New Issue
Block a user