From 720c98765f3cf34b5a9558754c5d732e69c2ccb7 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 4 Jun 2026 11:07:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=90=88=E5=90=8C=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E9=80=9A=E7=94=A8=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、删除旧费用附件 API、CRUD、模型和专用前端面板,统一改用通用附件能力。 2、调整合同费用接口为 study_id 语义,并补充合同、凭证、发票等附件类型的权限映射。 3、将合同费用新建和编辑改为抽屉流程,详情页与列表页复用同一编辑组件。 4、补充数据库迁移、附件列表、合同费用抽屉和权限场景测试,覆盖旧权限移除与新流程。 --- ...20260528_01_drop_legacy_fee_attachments.py | 25 + ..._fee_attachment_create_read_permissions.py | 132 +++ ...ness_attachment_create_read_permissions.py | 139 +++ ..._equipment_attachment_delete_permission.py | 119 +++ backend/app/api/v1/attachments.py | 106 ++- backend/app/api/v1/fees_attachments.py | 255 ------ backend/app/api/v1/fees_contracts.py | 107 ++- backend/app/core/deps.py | 38 +- backend/app/crud/contract_fee.py | 12 +- backend/app/crud/fee_attachment.py | 70 -- backend/app/db/base.py | 1 - backend/app/models/contract_fee.py | 4 + backend/app/models/fee_attachment.py | 28 - backend/app/schemas/contract_fee.py | 8 +- backend/app/schemas/fee_attachment.py | 24 - .../tests/test_contract_fee_basic_fields.py | 40 +- backend/tests/test_study_lock_dependency.py | 68 ++ frontend/src/api/feeContracts.test.ts | 23 + frontend/src/api/feeContracts.ts | 6 +- .../attachments/AttachmentList.test.ts | 117 +++ .../components/attachments/AttachmentList.vue | 460 ++++++++-- .../attachments/AttachmentUploader.vue | 68 -- .../components/fees/FeeAttachmentPanel.vue | 410 --------- frontend/src/locales/zh-CN.ts | 11 +- .../src/utils/attachmentPermissions.test.ts | 28 +- frontend/src/utils/attachmentPermissions.ts | 88 +- .../src/views/fees/ContractFeeDetail.test.ts | 65 ++ frontend/src/views/fees/ContractFeeDetail.vue | 234 ++++-- .../fees/ContractFeeEditorDrawer.test.ts | 75 ++ .../views/fees/ContractFeeEditorDrawer.vue | 795 ++++++++++++++++++ .../src/views/fees/ContractFeeForm.test.ts | 33 - frontend/src/views/fees/ContractFeeForm.vue | 693 --------------- .../src/views/fees/ContractFeeLocale.test.ts | 13 + .../views/fees/ContractFeePermissions.test.ts | 25 + frontend/src/views/fees/ContractFees.test.ts | 38 + frontend/src/views/fees/ContractFees.vue | 93 +- 36 files changed, 2576 insertions(+), 1875 deletions(-) create mode 100644 backend/alembic/versions/20260528_01_drop_legacy_fee_attachments.py create mode 100644 backend/alembic/versions/20260529_02_remove_contract_fee_attachment_create_read_permissions.py create mode 100644 backend/alembic/versions/20260529_03_remove_business_attachment_create_read_permissions.py create mode 100644 backend/alembic/versions/20260529_04_add_material_equipment_attachment_delete_permission.py delete mode 100644 backend/app/api/v1/fees_attachments.py delete mode 100644 backend/app/crud/fee_attachment.py delete mode 100644 backend/app/models/fee_attachment.py delete mode 100644 backend/app/schemas/fee_attachment.py create mode 100644 frontend/src/api/feeContracts.test.ts create mode 100644 frontend/src/components/attachments/AttachmentList.test.ts delete mode 100644 frontend/src/components/attachments/AttachmentUploader.vue delete mode 100644 frontend/src/components/fees/FeeAttachmentPanel.vue create mode 100644 frontend/src/views/fees/ContractFeeDetail.test.ts create mode 100644 frontend/src/views/fees/ContractFeeEditorDrawer.test.ts create mode 100644 frontend/src/views/fees/ContractFeeEditorDrawer.vue delete mode 100644 frontend/src/views/fees/ContractFeeForm.test.ts delete mode 100644 frontend/src/views/fees/ContractFeeForm.vue create mode 100644 frontend/src/views/fees/ContractFeeLocale.test.ts create mode 100644 frontend/src/views/fees/ContractFeePermissions.test.ts create mode 100644 frontend/src/views/fees/ContractFees.test.ts diff --git a/backend/alembic/versions/20260528_01_drop_legacy_fee_attachments.py b/backend/alembic/versions/20260528_01_drop_legacy_fee_attachments.py new file mode 100644 index 00000000..8796e6fb --- /dev/null +++ b/backend/alembic/versions/20260528_01_drop_legacy_fee_attachments.py @@ -0,0 +1,25 @@ +"""drop legacy fee attachments + +Revision ID: 20260528_01 +Revises: 20260527_09 +Create Date: 2026-05-28 17:20:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "20260528_01" +down_revision: Union[str, None] = "20260527_09" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS fee_attachments") + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/20260529_02_remove_contract_fee_attachment_create_read_permissions.py b/backend/alembic/versions/20260529_02_remove_contract_fee_attachment_create_read_permissions.py new file mode 100644 index 00000000..cf65c4e3 --- /dev/null +++ b/backend/alembic/versions/20260529_02_remove_contract_fee_attachment_create_read_permissions.py @@ -0,0 +1,132 @@ +"""remove contract fee attachment create and read permissions + +Revision ID: 20260529_02 +Revises: 20260529_01 +Create Date: 2026-05-29 15:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260529_02" +down_revision: Union[str, None] = "20260529_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +REMOVED_KEYS = ( + "fees_contracts_attachments:create", + "fees_contracts_attachments:read", +) + +RESTORE_FROM_PARENT = { + "fees_contracts_attachments:create": "fees_contracts:create", + "fees_contracts_attachments:read": "fees_contracts:read", +} + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _remove_permission_rows() -> None: + quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS) + op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})") + + +def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg(role_key, role_permissions - '{key}') + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{key}' + ) + """ + ) + + +def _restore_permission_rows(new_key: str, source_key: str) -> None: + op.execute( + f""" + INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at) + SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW() + FROM api_endpoint_permissions AS source + WHERE source.endpoint_key = '{source_key}' + AND NOT EXISTS ( + SELECT 1 + FROM api_endpoint_permissions AS target + WHERE target.study_id = source.study_id + AND target.role = source.role + AND target.endpoint_key = '{new_key}' + ) + """ + ) + + +def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg( + role_key, + CASE + WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}' + THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}') + ELSE role_permissions + END + ) + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{source_key}' + ) + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + _remove_permission_rows() + + if _table_exists(inspector, "permission_templates"): + for key in REMOVED_KEYS: + _remove_template_key("permission_templates", key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for key in REMOVED_KEYS: + _remove_template_key("permission_template_versions", key) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_permission_rows(new_key, source_key) + + if _table_exists(inspector, "permission_templates"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_template_key("permission_template_versions", new_key, source_key) diff --git a/backend/alembic/versions/20260529_03_remove_business_attachment_create_read_permissions.py b/backend/alembic/versions/20260529_03_remove_business_attachment_create_read_permissions.py new file mode 100644 index 00000000..6199da0d --- /dev/null +++ b/backend/alembic/versions/20260529_03_remove_business_attachment_create_read_permissions.py @@ -0,0 +1,139 @@ +"""remove business attachment create and read permissions + +Revision ID: 20260529_03 +Revises: 20260529_02 +Create Date: 2026-05-29 15:12:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260529_03" +down_revision: Union[str, None] = "20260529_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +RESTORE_FROM_PARENT = { + "startup_initiation_attachments:create": "startup_initiation:create", + "startup_initiation_attachments:read": "startup_initiation:read", + "startup_ethics_attachments:create": "startup_ethics:create", + "startup_ethics_attachments:read": "startup_ethics:read", + "startup_auth_attachments:create": "startup_auth:create", + "startup_auth_attachments:read": "startup_auth:read", + "drug_shipments_attachments:create": "drug_shipments:create", + "drug_shipments_attachments:read": "drug_shipments:read", + "precautions_attachments:create": "precautions:create", + "precautions_attachments:read": "precautions:read", + "faq_attachments:create": "faq_reply:create", + "faq_attachments:read": "faq:read", +} + +REMOVED_KEYS = tuple(RESTORE_FROM_PARENT) + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _remove_permission_rows() -> None: + quoted_keys = ", ".join(f"'{key}'" for key in REMOVED_KEYS) + op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key IN ({quoted_keys})") + + +def _remove_template_key(table_name: str, key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg(role_key, role_permissions - '{key}') + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{key}' + ) + """ + ) + + +def _restore_permission_rows(new_key: str, source_key: str) -> None: + op.execute( + f""" + INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at) + SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{new_key}', source.allowed, NOW() + FROM api_endpoint_permissions AS source + WHERE source.endpoint_key = '{source_key}' + AND NOT EXISTS ( + SELECT 1 + FROM api_endpoint_permissions AS target + WHERE target.study_id = source.study_id + AND target.role = source.role + AND target.endpoint_key = '{new_key}' + ) + """ + ) + + +def _restore_template_key(table_name: str, new_key: str, source_key: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg( + role_key, + CASE + WHEN role_permissions ? '{source_key}' AND NOT role_permissions ? '{new_key}' + THEN role_permissions || jsonb_build_object('{new_key}', role_permissions -> '{source_key}') + ELSE role_permissions + END + ) + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{source_key}' + ) + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + _remove_permission_rows() + + if _table_exists(inspector, "permission_templates"): + for key in REMOVED_KEYS: + _remove_template_key("permission_templates", key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for key in REMOVED_KEYS: + _remove_template_key("permission_template_versions", key) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_permission_rows(new_key, source_key) + + if _table_exists(inspector, "permission_templates"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_template_key("permission_templates", new_key, source_key, touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + for new_key, source_key in RESTORE_FROM_PARENT.items(): + _restore_template_key("permission_template_versions", new_key, source_key) diff --git a/backend/alembic/versions/20260529_04_add_material_equipment_attachment_delete_permission.py b/backend/alembic/versions/20260529_04_add_material_equipment_attachment_delete_permission.py new file mode 100644 index 00000000..f19e80de --- /dev/null +++ b/backend/alembic/versions/20260529_04_add_material_equipment_attachment_delete_permission.py @@ -0,0 +1,119 @@ +"""add material equipment attachment delete permission + +Revision ID: 20260529_04 +Revises: 20260529_03 +Create Date: 2026-05-29 15:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260529_04" +down_revision: Union[str, None] = "20260529_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +SOURCE_KEY = "material_equipments:update" +TARGET_KEY = "material_equipments_attachments:delete" + + +def _table_exists(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _copy_permission_rows() -> None: + op.execute( + f""" + INSERT INTO api_endpoint_permissions (id, study_id, role, endpoint_key, allowed, updated_at) + SELECT (md5(random()::text || clock_timestamp()::text))::uuid, source.study_id, source.role, '{TARGET_KEY}', source.allowed, NOW() + FROM api_endpoint_permissions AS source + WHERE source.endpoint_key = '{SOURCE_KEY}' + AND NOT EXISTS ( + SELECT 1 + FROM api_endpoint_permissions AS target + WHERE target.study_id = source.study_id + AND target.role = source.role + AND target.endpoint_key = '{TARGET_KEY}' + ) + """ + ) + + +def _remove_permission_rows() -> None: + op.execute(f"DELETE FROM api_endpoint_permissions WHERE endpoint_key = '{TARGET_KEY}'") + + +def _copy_template_key(table_name: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg( + role_key, + CASE + WHEN role_permissions ? '{SOURCE_KEY}' AND NOT role_permissions ? '{TARGET_KEY}' + THEN role_permissions || jsonb_build_object('{TARGET_KEY}', role_permissions -> '{SOURCE_KEY}') + ELSE role_permissions + END + ) + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{SOURCE_KEY}' + ) + """ + ) + + +def _remove_template_key(table_name: str, *, touch_updated_at: bool = False) -> None: + updated_at_assignment = ", updated_at = NOW()" if touch_updated_at else "" + op.execute( + f""" + UPDATE {table_name} + SET permissions = ( + SELECT jsonb_object_agg(role_key, role_permissions - '{TARGET_KEY}') + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + ){updated_at_assignment} + WHERE EXISTS ( + SELECT 1 + FROM jsonb_each(permissions::jsonb) AS role_entries(role_key, role_permissions) + WHERE role_permissions ? '{TARGET_KEY}' + ) + """ + ) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + _copy_permission_rows() + + if _table_exists(inspector, "permission_templates"): + _copy_template_key("permission_templates", touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + _copy_template_key("permission_template_versions") + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if _table_exists(inspector, "api_endpoint_permissions"): + _remove_permission_rows() + + if _table_exists(inspector, "permission_templates"): + _remove_template_key("permission_templates", touch_updated_at=True) + + if _table_exists(inspector, "permission_template_versions"): + _remove_template_key("permission_template_versions") diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index af4f97db..ce1d393e 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -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, diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py deleted file mode 100644 index 7922a51f..00000000 --- a/backend/app/api/v1/fees_attachments.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index c0defcdb..ddbb12a1 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -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), ) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index c0730f84..d14a4140 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -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", diff --git a/backend/app/crud/contract_fee.py b/backend/app/crud/contract_fee.py index 5da7d27c..155a0626 100644 --- a/backend/app/crud/contract_fee.py +++ b/backend/app/crud/contract_fee.py @@ -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) ) diff --git a/backend/app/crud/fee_attachment.py b/backend/app/crud/fee_attachment.py deleted file mode 100644 index 403f3d55..00000000 --- a/backend/app/crud/fee_attachment.py +++ /dev/null @@ -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 diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 7b7cb324..feedbf49 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/models/contract_fee.py b/backend/app/models/contract_fee.py index 27b3f8a2..a08e6516 100644 --- a/backend/app/models/contract_fee.py +++ b/backend/app/models/contract_fee.py @@ -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 diff --git a/backend/app/models/fee_attachment.py b/backend/app/models/fee_attachment.py deleted file mode 100644 index c8723ca1..00000000 --- a/backend/app/models/fee_attachment.py +++ /dev/null @@ -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") diff --git a/backend/app/schemas/contract_fee.py b/backend/app/schemas/contract_fee.py index d1f8b42b..e9866e49 100644 --- a/backend/app/schemas/contract_fee.py +++ b/backend/app/schemas/contract_fee.py @@ -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]] diff --git a/backend/app/schemas/fee_attachment.py b/backend/app/schemas/fee_attachment.py deleted file mode 100644 index df344509..00000000 --- a/backend/app/schemas/fee_attachment.py +++ /dev/null @@ -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) diff --git a/backend/tests/test_contract_fee_basic_fields.py b/backend/tests/test_contract_fee_basic_fields.py index 6bf6020e..1af958bc 100644 --- a/backend/tests/test_contract_fee_basic_fields.py +++ b/backend/tests/test_contract_fee_basic_fields.py @@ -4,17 +4,49 @@ from datetime import date from decimal import Decimal import uuid +from starlette.requests import Request + +from app.api.v1 import fees_contracts from app.models.contract_fee import ContractFee from app.schemas.contract_fee import ContractFeeCreate, ContractFeeRead, ContractFeeUpdate +def _request_with_query(query: str) -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/api/v1/fees/contracts", + "query_string": query.encode(), + "headers": [], + } + ) + + +def test_contract_fee_list_query_accepts_legacy_project_params(): + """合同费用列表应兼容旧 projectId/centerId 查询参数。""" + study_id = uuid.uuid4() + center_id = uuid.uuid4() + resolver = getattr(fees_contracts, "_resolve_contract_fee_list_query", None) + + assert resolver is not None + resolved_study_id, resolved_center_id = resolver( + _request_with_query(f"projectId={study_id}¢erId={center_id}"), + None, + None, + ) + + assert resolved_study_id == study_id + assert resolved_center_id == center_id + + def test_contract_fee_schema_includes_contract_basic_fields(): """合同费用应包含合同基础信息字段。""" - project_id = uuid.uuid4() + study_id = uuid.uuid4() center_id = uuid.uuid4() payload = ContractFeeCreate( - project_id=project_id, + study_id=study_id, center_id=center_id, contract_no="CT-001", signed_date=date(2026, 5, 27), @@ -28,6 +60,8 @@ def test_contract_fee_schema_includes_contract_basic_fields(): assert payload.signed_date == date(2026, 5, 27) assert payload.currency == "CNY" assert payload.remark == "首版合同" + assert payload.study_id == study_id + assert "project_id" not in ContractFeeCreate.model_fields update_payload = ContractFeeUpdate(contract_no="CT-002", currency="USD", remark="变更合同信息") assert update_payload.model_dump(exclude_unset=True) == { @@ -38,6 +72,8 @@ def test_contract_fee_schema_includes_contract_basic_fields(): for field in ("contract_no", "signed_date", "currency", "remark"): assert field in ContractFeeRead.model_fields + assert "study_id" in ContractFeeRead.model_fields + assert "project_id" not in ContractFeeRead.model_fields def test_contract_fee_model_includes_contract_basic_columns(): diff --git a/backend/tests/test_study_lock_dependency.py b/backend/tests/test_study_lock_dependency.py index 805df935..ca2d16fb 100644 --- a/backend/tests/test_study_lock_dependency.py +++ b/backend/tests/test_study_lock_dependency.py @@ -23,6 +23,16 @@ class _FaqItem: self.study_id = study_id +class _ContractFee: + def __init__(self, study_id: uuid.UUID | None): + self.study_id = study_id + + +class _ContractPayment: + def __init__(self, contract_fee_id: uuid.UUID): + self.contract_fee_id = contract_fee_id + + def _make_request(*, method: str = "POST", path: str = "/", query_string: bytes = b"", json_body: bytes = b"", path_params=None): async def receive(): return {"type": "http.request", "body": json_body, "more_body": False} @@ -80,6 +90,64 @@ async def test_require_study_not_locked_resolves_study_id_from_category_path(mon assert study.id == study_id +@pytest.mark.asyncio +async def test_require_study_not_locked_resolves_study_id_from_contract_path(monkeypatch): + study_id = uuid.uuid4() + contract_id = uuid.uuid4() + + async def fake_get_contract(_db, requested_contract_id): + assert requested_contract_id == contract_id + return _ContractFee(study_id) + + async def fake_get_study(_db, requested_study_id): + return _Study(requested_study_id, is_locked=False) + + monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract) + monkeypatch.setattr("app.crud.study.get", fake_get_study) + + dependency = deps.require_study_not_locked() + request = _make_request( + path=f"/api/v1/fees/contracts/{contract_id}", + path_params={"contract_id": str(contract_id)}, + ) + + study = await dependency(request=request, db=object()) + + assert study.id == study_id + + +@pytest.mark.asyncio +async def test_require_study_not_locked_resolves_study_id_from_contract_payment_path(monkeypatch): + study_id = uuid.uuid4() + contract_id = uuid.uuid4() + payment_id = uuid.uuid4() + + async def fake_get_payment(_db, requested_payment_id): + assert requested_payment_id == payment_id + return _ContractPayment(contract_id) + + async def fake_get_contract(_db, requested_contract_id): + assert requested_contract_id == contract_id + return _ContractFee(study_id) + + async def fake_get_study(_db, requested_study_id): + return _Study(requested_study_id, is_locked=False) + + monkeypatch.setattr("app.crud.contract_fee_payment.get_payment", fake_get_payment) + monkeypatch.setattr("app.crud.contract_fee.get_contract_fee", fake_get_contract) + monkeypatch.setattr("app.crud.study.get", fake_get_study) + + dependency = deps.require_study_not_locked() + request = _make_request( + path=f"/api/v1/fees/payments/{payment_id}", + path_params={"payment_id": str(payment_id)}, + ) + + study = await dependency(request=request, db=object()) + + assert study.id == study_id + + @pytest.mark.asyncio async def test_require_study_not_locked_rejects_locked_study_resolved_from_item_path(monkeypatch): study_id = uuid.uuid4() diff --git a/frontend/src/api/feeContracts.test.ts b/frontend/src/api/feeContracts.test.ts new file mode 100644 index 00000000..0daf3d17 --- /dev/null +++ b/frontend/src/api/feeContracts.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readSource = () => readFileSync(resolve(__dirname, "./feeContracts.ts"), "utf8"); + +describe("feeContracts API naming", () => { + it("uses study_id for contract fee project identity", () => { + const source = readSource(); + + expect(source).toContain("study_id: string"); + expect(source).toContain("params: { study_id: string; center_id?: string; q?: string }"); + expect(source).not.toContain("project_id: string"); + expect(source).not.toContain("projectId"); + expect(source).not.toContain("centerId"); + }); + + it("does not expose currency in contract fee payloads because amounts are fixed to ten-thousand yuan", () => { + const source = readSource(); + + expect(source).not.toContain("currency?: string"); + }); +}); diff --git a/frontend/src/api/feeContracts.ts b/frontend/src/api/feeContracts.ts index 0659adcb..37d5ed5d 100644 --- a/frontend/src/api/feeContracts.ts +++ b/frontend/src/api/feeContracts.ts @@ -2,12 +2,11 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { FeeApiResponse } from "../types/api"; export interface ContractFeePayload { - project_id: string; + study_id: string; center_id: string; contract_no?: string | null; signed_date?: string | null; contract_amount: number; - currency?: string; remark?: string | null; contract_cases: number; actual_cases?: number | null; @@ -17,7 +16,6 @@ export interface ContractFeeUpdatePayload { contract_no?: string | null; signed_date?: string | null; contract_amount?: number; - currency?: string; remark?: string | null; contract_cases?: number; actual_cases?: number | null; @@ -32,7 +30,7 @@ export interface ContractPaymentPayload { remark?: string | null; } -export const listContractFees = (params: { projectId: string; centerId?: string; q?: string }) => +export const listContractFees = (params: { study_id: string; center_id?: string; q?: string }) => apiGet>("/api/v1/fees/contracts", { params }); export const getContractFee = (contractId: string) => apiGet>(`/api/v1/fees/contracts/${contractId}`); diff --git a/frontend/src/components/attachments/AttachmentList.test.ts b/frontend/src/components/attachments/AttachmentList.test.ts new file mode 100644 index 00000000..c161ce79 --- /dev/null +++ b/frontend/src/components/attachments/AttachmentList.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readAttachmentList = () => readFileSync(resolve(__dirname, "./AttachmentList.vue"), "utf8"); + +describe("AttachmentList permissions", () => { + it("does not require project member list permission to render uploaders", () => { + const source = readAttachmentList(); + + expect(source).not.toContain("listMembers"); + }); + + it("matches the contract-fee attachment table layout", () => { + const source = readAttachmentList(); + + expect(source).toContain('prop="filename" :label="TEXT.common.labels.filename" min-width="360" show-overflow-tooltip'); + expect(source).toContain(':label="TEXT.common.labels.size" min-width="180"'); + expect(source).toContain(':label="TEXT.common.labels.uploader" min-width="180"'); + expect(source).toContain('prop="uploaded_at" :label="TEXT.common.labels.uploadedAt" min-width="180" show-overflow-tooltip'); + expect(source).toContain(':label="TEXT.common.labels.actions" min-width="180"'); + expect(source).not.toContain('width="160"'); + expect(source).toContain('class="attachment-actions"'); + expect(source).toContain("flex-wrap: nowrap;"); + expect(source).toContain("gap: 4px;"); + }); + + it("can aggregate multiple attachment entity types into one table with an attachment type column", () => { + const source = readAttachmentList(); + + expect(source).toContain("entityGroups?: AttachmentEntityGroup[]"); + expect(source).toContain('v-if="hasEntityGroups"'); + expect(source).toContain('label="附件类型"'); + expect(source).toContain("scope.row.attachment_type_label"); + expect(source).toContain("fetchAttachments(props.studyId, group.entityType, props.entityId)"); + expect(source).toContain("attachment_type_label: group.label"); + expect(source).toContain("attachment_entity_type: group.entityType"); + }); + + it("can reload attachments when the parent refresh key changes", () => { + const source = readAttachmentList(); + + expect(source).toContain("refreshKey?: number"); + expect(source).toContain("() => props.refreshKey"); + expect(source).toContain("load();"); + }); + + it("can hide the uploader while keeping the attachment table visible", () => { + const source = readAttachmentList(); + + expect(source).toContain("hideUploader?: boolean"); + expect(source).toContain("canShowUploader"); + expect(source).toContain("!props.hideUploader && canCreate.value"); + }); + + it("hides the default attachment title row when uploader is hidden and no explicit title is provided", () => { + const source = readAttachmentList(); + + expect(source).toContain("const showHeader = computed(() => !!props.title || canShowUploader.value)"); + expect(source).toContain('
'); + }); + + it("uses the shared upload implementation for table-mode immediate uploads", () => { + const source = readAttachmentList(); + + expect(source).not.toContain("AttachmentUploader"); + expect(source).toContain(':http-request="uploadImmediate"'); + expect(source).toContain("const uploadImmediate = async"); + expect(source).toContain("await uploadFile(group, file, props.entityId)"); + expect(source).toContain("ElMessage.success(TEXT.common.messages.uploadSuccess)"); + expect(source).toContain("load();"); + }); + + it("supports upload-card mode without rendering the table header", () => { + const source = readAttachmentList(); + + expect(source).toContain('mode?: "table" | "upload"'); + expect(source).toContain("displayMode"); + expect(source).toContain('v-if="displayMode === \'table\'"'); + expect(source).toContain("v-else"); + expect(source).toContain('class="attachment-upload-card"'); + expect(source).toContain(':auto-upload="false"'); + expect(source).toContain("queuePendingUpload(group.key, file)"); + expect(source).toContain("点击上传文件"); + }); + + it("allows create forms to choose attachments before the entity id exists", () => { + const source = readAttachmentList(); + + expect(source).toContain("const pendingSnapshot = () =>"); + expect(source).toContain("const uploadPending = async"); + expect(source).toContain("const targetEntityId = entityId || props.entityId"); + expect(source).toContain("defineExpose({"); + expect(source).toContain("pendingSnapshot"); + expect(source).toContain("uploadPending"); + expect(source).toContain("pendingMap[group.key] = remainItems"); + expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)"); + }); + + it("fails pending upload when selected files have no saved entity id", () => { + const source = readAttachmentList(); + + expect(source).toContain("const hasPendingUploads = computed"); + expect(source).toContain("if (!targetEntityId && hasPendingUploads.value)"); + expect(source).toContain("throw new Error(TEXT.common.messages.uploadFailed)"); + }); + + it("uses entity groups as upload groups when an editor has multiple attachment types", () => { + const source = readAttachmentList(); + + expect(source).toContain("const uploadGroups = computed"); + expect(source).toContain("props.entityGroups.map((group)"); + expect(source).toContain("key: group.entityType"); + expect(source).toContain("entityType: group.entityType"); + expect(source).toContain("uploadAttachment(props.studyId, group.entityType, targetEntityId, file"); + }); +}); diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 2a2de501..aeeedacf 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -1,48 +1,98 @@ +
+
+
{{ group.label }}
+ +
+ + 点击上传文件 +
+
+
+
+ {{ item.file.name }} + + {{ item.error || TEXT.common.messages.uploadFailed }} + + + {{ TEXT.common.actions.remove }} + +
+
+ +
+
@@ -60,33 +110,57 @@ diff --git a/frontend/src/components/fees/FeeAttachmentPanel.vue b/frontend/src/components/fees/FeeAttachmentPanel.vue deleted file mode 100644 index 88f31d0a..00000000 --- a/frontend/src/components/fees/FeeAttachmentPanel.vue +++ /dev/null @@ -1,410 +0,0 @@ -