迁移合同费用通用附件

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,