迁移合同费用通用附件

1、删除旧费用附件 API、CRUD、模型和专用前端面板,统一改用通用附件能力。

2、调整合同费用接口为 study_id 语义,并补充合同、凭证、发票等附件类型的权限映射。

3、将合同费用新建和编辑改为抽屉流程,详情页与列表页复用同一编辑组件。

4、补充数据库迁移、附件列表、合同费用抽屉和权限场景测试,覆盖旧权限移除与新流程。
This commit is contained in:
Cheng Zhou
2026-06-04 11:07:13 +08:00
parent ab4f0d93ed
commit 720c98765f
36 changed files with 2576 additions and 1875 deletions
+60 -46
View File
@@ -15,6 +15,7 @@ from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import drug_shipment as drug_shipment_crud
from app.crud import faq_reply as faq_reply_crud
from app.crud import material_equipment as material_equipment_crud
from app.crud import precaution as precaution_crud
from app.crud import study as study_crud
from app.crud import startup as startup_crud
@@ -33,6 +34,49 @@ FEE_ATTACHMENT_ENTITY_TYPES = {
"contract_fee_voucher",
"contract_fee_invoice",
}
FEE_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "fees_contracts:create",
"read": "fees_contracts:read",
"delete": "fees_contracts_attachments:delete",
}
STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_initiation:create",
"read": "startup_initiation:read",
"delete": "startup_initiation_attachments:delete",
}
STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_ethics:create",
"read": "startup_ethics:read",
"delete": "startup_ethics_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "startup_auth:create",
"read": "startup_auth:read",
"delete": "startup_auth_attachments:delete",
}
DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "drug_shipments:create",
"read": "drug_shipments:read",
"delete": "drug_shipments_attachments:delete",
}
MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES = {
"material_equipment",
}
MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "material_equipments:update",
"read": "material_equipments:read",
"delete": "material_equipments_attachments:delete",
}
PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "precautions:create",
"read": "precautions:read",
"delete": "precautions_attachments:delete",
}
FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"create": "faq_reply:create",
"read": "faq:read",
"delete": "faq_attachments:delete",
}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff",
"startup_kickoff_minutes",
@@ -41,23 +85,6 @@ STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"training_authorization",
}
ATTACHMENT_PERMISSION_MODULES = {
"contract_fee_contract": "fees_contracts_attachments",
"contract_fee_voucher": "fees_contracts_attachments",
"contract_fee_invoice": "fees_contracts_attachments",
"startup_feasibility": "startup_initiation_attachments",
"startup_ethics": "startup_ethics_attachments",
"startup_kickoff": "startup_auth_attachments",
"startup_kickoff_minutes": "startup_auth_attachments",
"startup_kickoff_signin": "startup_auth_attachments",
"startup_kickoff_ppt": "startup_auth_attachments",
"training_authorization": "startup_auth_attachments",
"drug_shipment": "drug_shipments_attachments",
"precaution": "precautions_attachments",
"faq_replies": "faq_attachments",
}
def _content_disposition(filename: str, disposition: str = "inline") -> str:
fallback = "".join((ch if ord(ch) < 128 else "_") for ch in filename) or "download"
quoted = quote(filename, safe="")
@@ -84,25 +111,25 @@ async def _resolve_attachment_parent_permission(
entity_id: uuid.UUID,
action: str,
) -> str | None:
parent_action = "read" if action == "read" else "update"
operation = _attachment_operation(action)
if entity_type in FEE_ATTACHMENT_ENTITY_TYPES:
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
if not contract or contract.project_id != study_id:
if not contract or contract.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return "fees_contracts:read" if action == "read" else "fees_contracts:update"
return FEE_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_feasibility":
record = await startup_crud.get_feasibility(db, entity_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="立项记录不存在")
return f"startup_initiation:{parent_action}"
return STARTUP_INITIATION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "startup_ethics":
record = await startup_crud.get_ethics(db, entity_id)
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="伦理记录不存在")
return f"startup_ethics:{parent_action}"
return STARTUP_ETHICS_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES:
if entity_type == "training_authorization":
@@ -113,29 +140,31 @@ async def _resolve_attachment_parent_permission(
missing_detail = "启动会不存在"
if not record or record.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=missing_detail)
return "startup_auth:read" if action == "read" else "startup_auth:update"
return STARTUP_AUTH_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "drug_shipment":
shipment = await drug_shipment_crud.get_shipment(db, entity_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="药物发货不存在")
return "drug_shipments:read" if action == "read" else "drug_shipments:update"
return DRUG_SHIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type in MATERIAL_EQUIPMENT_ATTACHMENT_ENTITY_TYPES:
equipment = await material_equipment_crud.get_equipment(db, entity_id)
if not equipment or equipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
return MATERIAL_EQUIPMENT_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "precaution":
precaution = await precaution_crud.get_precaution(db, entity_id)
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
return "precautions:read" if action == "read" else "precautions:update"
return PRECAUTION_ATTACHMENT_PERMISSION_BY_ACTION[operation]
if entity_type == "faq_replies":
reply = await faq_reply_crud.get_reply(db, entity_id)
if not reply or reply.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ回复不存在")
if action == "read":
return "faq:read"
if action == "delete":
return "faq_reply:delete"
return "faq_reply:create"
return FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION[operation]
return None
@@ -158,23 +187,8 @@ async def _ensure_attachment_permission(
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
operation = _attachment_operation(action)
module_prefix = ATTACHMENT_PERMISSION_MODULES.get(entity_type)
if not module_prefix:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
attachment_allowed = await role_has_api_permission(
db,
study_id,
membership.role_in_study,
f"{module_prefix}:{operation}",
check_prerequisites=False,
)
if not attachment_allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
if not parent_permission:
return
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="附件类型未配置权限")
allowed = await role_has_api_permission(
db,
-255
View File
@@ -1,255 +0,0 @@
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, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
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 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"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
}
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
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
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(require_api_permission("fees_contracts:update")),
Depends(require_study_not_locked())
],
)
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)
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(require_api_permission("fees_contracts:read"))],
)
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)
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)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != "ADMIN":
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(
db, project_id, membership.role_in_study, "fees_contracts:read", check_prerequisites=False
)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
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(require_api_permission("fees_contracts:update")),
Depends(require_study_not_locked())
],
)
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)
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
membership = None
if role_value != "ADMIN":
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="不是项目成员")
can_delete = (
role_value == "ADMIN"
or attachment.uploaded_by == current_user.id
or membership is not None
)
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,
)
+66 -41
View File
@@ -2,7 +2,7 @@ import uuid
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -29,29 +29,52 @@ from app.models.attachment import Attachment
router = APIRouter()
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, project_id)
async def _ensure_study_access(db: AsyncSession, study_id: uuid.UUID, current_user, endpoint_key: str):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if is_system_admin(current_user):
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
allowed = await role_has_api_permission(db, project_id, membership.role_in_study, endpoint_key)
allowed = await role_has_api_permission(db, study_id, membership.role_in_study, endpoint_key)
if not allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="接口权限不足")
return membership
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
async def _ensure_center_active(db: AsyncSession, study_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
if not site or site.study_id != study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _parse_optional_uuid(value: str | None, detail: str) -> uuid.UUID | None:
if not value:
return None
try:
return uuid.UUID(str(value))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail) from exc
def _resolve_contract_fee_list_query(
request: Request,
study_id: uuid.UUID | None,
center_id: uuid.UUID | None,
) -> tuple[uuid.UUID, uuid.UUID | None]:
query = request.query_params
resolved_study_id = study_id or _parse_optional_uuid(query.get("projectId"), "项目 ID 格式错误")
if not resolved_study_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="缺少项目 ID")
resolved_center_id = center_id or _parse_optional_uuid(query.get("centerId"), "中心 ID 格式错误")
return resolved_study_id, resolved_center_id
def _to_decimal(value: Any) -> Decimal:
if isinstance(value, Decimal):
return value
@@ -78,21 +101,23 @@ def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentU
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"),
request: Request,
study_id: uuid.UUID | None = None,
center_id: uuid.UUID | None = None,
q: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[ContractFeeListItem]]:
await _ensure_project_access(db, project_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, project_id, current_user)
resolved_study_id, resolved_center_id = _resolve_contract_fee_list_query(request, study_id, center_id)
await _ensure_study_access(db, resolved_study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, resolved_study_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
if resolved_center_id and center_ids is not None and resolved_center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await contract_fee_crud.list_contract_fees(
db,
project_id,
center_id=center_id,
resolved_study_id,
center_id=resolved_center_id,
center_ids=center_ids,
q=q,
)
@@ -106,7 +131,7 @@ async def list_contract_fees(
items.append(
ContractFeeListItem(
id=contract.id,
project_id=contract.project_id,
study_id=contract.study_id,
center_id=contract.center_id,
contract_no=contract.contract_no,
signed_date=contract.signed_date,
@@ -144,8 +169,8 @@ async def get_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.project_id, current_user)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:read")
cra_scope = await get_cra_site_scope(db, contract.study_id, current_user)
if cra_scope and contract.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
@@ -187,7 +212,7 @@ async def get_contract_fee(
detail = ContractFeeDetail(
id=contract.id,
project_id=contract.project_id,
study_id=contract.study_id,
center_id=contract.center_id,
contract_no=contract.contract_no,
signed_date=contract.signed_date,
@@ -218,27 +243,27 @@ async def create_contract_fee(
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[ContractFeeRead]:
await _ensure_project_access(db, contract_in.project_id, current_user, "fees_contracts:create")
existing = await contract_fee_crud.get_contract_fee_by_project_center(
db, contract_in.project_id, contract_in.center_id
await _ensure_study_access(db, contract_in.study_id, current_user, "fees_contracts:create")
existing = await contract_fee_crud.get_contract_fee_by_study_center(
db, contract_in.study_id, contract_in.center_id
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用")
site = await site_crud.get_site(db, contract_in.center_id)
if not site or site.study_id != contract_in.project_id or not site.is_active:
if not site or site.study_id != contract_in.study_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
contract = await contract_fee_crud.create_contract_fee(db, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract.id,
action="CREATE_CONTRACT_FEE",
detail="合同费用已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -257,18 +282,18 @@ async def update_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.project_id, contract.center_id)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await _ensure_center_active(db, contract.study_id, contract.center_id)
contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract_id,
action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -286,18 +311,18 @@ async def delete_contract_fee(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.project_id, contract.center_id)
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:delete")
await _ensure_center_active(db, contract.study_id, contract.center_id)
await contract_fee_crud.delete_contract_fee(db, contract)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee",
entity_id=contract_id,
action="DELETE_CONTRACT_FEE",
detail="合同费用已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
@@ -316,18 +341,18 @@ async def create_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
_validate_payment_rules(payment_in)
payment = await payment_crud.create_payment(db, contract_id, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment.id,
action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -349,7 +374,7 @@ async def update_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
merged_payment = ContractFeePaymentCreate(
amount=payment_in.amount if payment_in.amount is not None else payment.amount,
paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date,
@@ -362,13 +387,13 @@ async def update_contract_payment(
payment = await payment_crud.update_payment(db, payment, payment_in)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -389,16 +414,16 @@ async def delete_contract_payment(
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
await _ensure_project_access(db, contract.project_id, current_user, "fees_contracts:update")
await _ensure_study_access(db, contract.study_id, current_user, "fees_contracts:update")
await payment_crud.delete_payment(db, payment)
await payment_crud.resequence_payments(db, contract.id)
await audit_crud.log_action(
db,
study_id=contract.project_id,
study_id=contract.study_id,
entity_type="contract_fee_payment",
entity_id=payment_id,
action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除",
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
operator_role=await get_operator_role_label(db, contract.study_id, current_user),
)
+37 -1
View File
@@ -323,9 +323,14 @@ def require_study_not_locked():
from app.crud import study as study_crud
from app.crud import faq_category as faq_category_crud
from app.crud import faq_item as faq_item_crud
from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as contract_fee_payment_crud
async def resolve_study_id(request: Request, db: AsyncSession) -> uuid.UUID:
raw_study_id = request.path_params.get("study_id") or request.query_params.get("study_id")
raw_study_id = (
request.path_params.get("study_id")
or request.query_params.get("study_id")
)
if raw_study_id:
try:
return uuid.UUID(str(raw_study_id))
@@ -376,6 +381,37 @@ def require_study_not_locked():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
return item.study_id
raw_contract_id = request.path_params.get("contract_id")
if raw_contract_id:
try:
contract_id = uuid.UUID(str(raw_contract_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="合同费用 ID 格式错误",
) from exc
contract = await contract_fee_crud.get_contract_fee(db, contract_id)
if not contract or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raw_payment_id = request.path_params.get("payment_id")
if raw_payment_id:
try:
payment_id = uuid.UUID(str(raw_payment_id))
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="分期记录 ID 格式错误",
) from exc
payment = await contract_fee_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 or not contract.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.study_id
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="缺少项目 ID",
+6 -6
View File
@@ -16,7 +16,7 @@ async def create_contract_fee(
contract_in: ContractFeeCreate,
) -> ContractFee:
contract = ContractFee(
project_id=contract_in.project_id,
project_id=contract_in.study_id,
center_id=contract_in.center_id,
contract_no=contract_in.contract_no,
signed_date=contract_in.signed_date,
@@ -39,18 +39,18 @@ async def get_contract_fee(db: AsyncSession, contract_id: uuid.UUID) -> Contract
return result.scalar_one_or_none()
async def get_contract_fee_by_project_center(
db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID
async def get_contract_fee_by_study_center(
db: AsyncSession, study_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)
select(ContractFee).where(ContractFee.project_id == study_id, ContractFee.center_id == center_id)
)
return result.scalar_one_or_none()
async def list_contract_fees(
db: AsyncSession,
project_id: uuid.UUID,
study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
q: str | None = None,
@@ -81,7 +81,7 @@ async def list_contract_fees(
)
.join(Site, Site.id == ContractFee.center_id)
.outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id)
.where(ContractFee.project_id == project_id)
.where(ContractFee.project_id == study_id)
.group_by(ContractFee.id, Site.name)
)
-70
View File
@@ -1,70 +0,0 @@
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
-1
View File
@@ -20,7 +20,6 @@ from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401
from app.models.contract_fee import ContractFee # noqa: F401
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
from app.models.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # noqa: F401
from app.models.monitoring_visit_issue import MonitoringVisitIssue # noqa: F401
+4
View File
@@ -34,3 +34,7 @@ class ContractFee(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
@property
def study_id(self) -> uuid.UUID:
return self.project_id
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
from typing import Optional
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[Optional[str]] = mapped_column(String(100), nullable=True)
size: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
url: Mapped[Optional[str]] = 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")
+4 -4
View File
@@ -5,12 +5,12 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.attachment import AttachmentRead
from app.schemas.contract_fee_payment import ContractFeePaymentRead
from app.schemas.fee_attachment import FeeAttachmentRead
class ContractFeeCreate(BaseModel):
project_id: uuid.UUID
study_id: uuid.UUID
center_id: uuid.UUID
contract_no: Optional[str] = None
signed_date: Optional[date] = None
@@ -37,7 +37,7 @@ class ContractFeeUpdate(BaseModel):
class ContractFeeRead(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
study_id: uuid.UUID
center_id: uuid.UUID
contract_no: Optional[str]
signed_date: Optional[date]
@@ -66,4 +66,4 @@ class ContractFeeListItem(ContractFeeRead):
class ContractFeeDetail(ContractFeeRead):
payments: list[ContractFeePaymentRead]
attachments: dict[str, list[FeeAttachmentRead]]
attachments: dict[str, list[AttachmentRead]]
-24
View File
@@ -1,24 +0,0 @@
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)