From 93cd506a9023b43eb84a7380ab26bacca4ef9b2e Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 19 May 2026 12:03:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.pid | 2 +- backend/app/api/admin.py | 31 + backend/app/api/menu.py | 91 +- backend/app/api/pilot_service.py | 10 +- backend/app/api/production.py | 41 +- backend/app/api/research_donation.py | 97 +- backend/app/core/storage.py | 43 +- backend/app/crud/collection_protocol.py | 114 +- backend/app/crud/consumable.py | 6 + backend/app/crud/consumable_pack.py | 9 +- backend/app/crud/pilot_service.py | 93 +- backend/app/crud/process.py | 56 +- backend/app/crud/protocol_donation_convert.py | 311 ++ backend/app/crud/qc_task.py | 16 +- backend/app/crud/research_donation.py | 258 +- backend/app/crud/sop_document.py | 11 +- backend/app/models/__init__.py | 3 + backend/app/models/models.py | 21 + backend/app/models/research_donation.py | 21 + docs/sql/biz_protocol_donation_convert.sql | 21 + docs/sql/cell_storage_menu_reorder.sql | 8 + docs/sql/production.sql | 23 +- docs/sql/production_consumable_add_unit.sql | 9 + docs/sql/production_menu_reorder.sql | 10 + .../production_process_data_collection.sql | 23 + docs/sql/research_donation_menu_rename.sql | 6 + docs/sql/research_donation_tables.sql | 4 +- docs/sql/seed_production_basic_data.sql | 155 + docs/sql/zkxw.sql | 10 +- ...6-05-19-process-route-management-design.md | 322 ++ ...-05-19-protocol-donation-convert-design.md | 339 ++ opencode.json | 10 + web-admin/src/api/business.ts | 7 + web-admin/src/api/qc-task.ts | 1 + web-admin/src/api/research-donation.ts | 2 +- .../protocol-finance-confirm-fields/index.vue | 9 +- .../business/system-view-dialog/index.vue | 1 + web-admin/src/router/modules/sqxp.ts | 55 +- web-admin/src/types/api.d.ts | 4 +- web-admin/src/utils/download-attachment.ts | 45 + web-admin/src/utils/fullscreen-loading.ts | 5 +- .../cell-storage/payment-confirm/index.vue | 10 +- .../cell-storage/protocol-audit/index.vue | 6 +- .../protocol/ProtocolFormDialog.vue | 25 +- .../protocol/ProtocolStatusChangeDialog.vue | 2 +- .../protocol/ProtocolViewDialog.vue | 17 +- .../src/views/cell-storage/protocol/index.vue | 6 +- .../cell-storage/refund-confirm/index.vue | 10 +- .../cell-product-price/index.vue | 14 +- .../pilot-service/cell-product-spec/index.vue | 14 +- .../pilot-service/cell-product-type/index.vue | 14 +- .../channel/ChannelViewDialog.vue | 24 +- .../src/views/pilot-service/channel/index.vue | 470 ++- .../CheckupPackagePanel.vue | 15 +- .../checkup-institution/index.vue | 33 +- .../project-price-system/index.vue | 14 +- .../production/consumable-pack/index.vue | 345 +- .../src/views/production/consumable/index.vue | 39 +- .../views/production/output-type/index.vue | 14 +- .../production/process-category/index.vue | 14 +- .../src/views/production/process/index.vue | 911 ++++- .../src/views/production/reagent/index.vue | 14 +- web-admin/src/views/production/sop/index.vue | 597 ++- .../quality-inspection/qc-task/index.vue | 85 +- .../sample-reception/index.vue | 10 +- .../views/research-donation/audit/index.vue | 400 +- .../list/DonationAuditDialog.vue | 60 +- .../list/DonationFormDialog.vue | 3240 ++++++++++++++--- .../list/DonationStatusChangeDialog.vue | 218 +- .../list/DonationViewDialog.vue | 1392 ++++--- .../views/research-donation/list/index.vue | 188 +- 71 files changed, 8297 insertions(+), 2207 deletions(-) create mode 100644 backend/app/crud/protocol_donation_convert.py create mode 100644 docs/sql/biz_protocol_donation_convert.sql create mode 100644 docs/sql/cell_storage_menu_reorder.sql create mode 100644 docs/sql/production_consumable_add_unit.sql create mode 100644 docs/sql/production_menu_reorder.sql create mode 100644 docs/sql/production_process_data_collection.sql create mode 100644 docs/sql/research_donation_menu_rename.sql create mode 100644 docs/sql/seed_production_basic_data.sql create mode 100644 docs/superpowers/specs/2026-05-19-process-route-management-design.md create mode 100644 docs/superpowers/specs/2026-05-19-protocol-donation-convert-design.md create mode 100644 opencode.json create mode 100644 web-admin/src/utils/download-attachment.ts diff --git a/backend/app.pid b/backend/app.pid index 7217e966b..97428a78e 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -38796 +11478 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index d678b750a..b52f2765e 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -24,6 +24,7 @@ from app.crud import express_company as express_company_svc from app.crud import menu as menu_svc from app.crud import role as role_svc from app.crud import user as user_svc +from app.crud.collection_protocol import _lookup_attachment_by_url from app.models.models import BizAttachment, SysRole, SysUser from app.services.web_auth_roles import _map_role_name_to_web @@ -1503,3 +1504,33 @@ async def upload_attachment( except Exception as e: db.rollback() return error_response(500, f"上传失败: {str(e)}") + + +class AttachmentDownloadUrlBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + file_url: str = Field(..., alias="fileUrl") + file_name: Optional[str] = Field(None, alias="fileName") + + +@router.post("/attachment/download-url") +async def attachment_download_url( + body: AttachmentDownloadUrlBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """返回带 Content-Disposition 的下载 URL,保证浏览器使用原始文件名。""" + file_url = (body.file_url or "").strip() + if not file_url: + return error_response(400, "文件地址不能为空") + tenant_no = _get_tenant_no(db, user) + att = _lookup_attachment_by_url(db, file_url, tenant_no) + download_name = (body.file_name or "").strip() + if not download_name and att: + download_name = (att.original_name or "").strip() + if not download_name: + download_name = file_url.rsplit("/", 1)[-1] or "download" + try: + url = storage.presigned_download_url(file_url, download_name) + return success_response({"downloadUrl": url}) + except Exception as e: + return error_response(500, f"获取下载链接失败: {str(e)}") diff --git a/backend/app/api/menu.py b/backend/app/api/menu.py index 8a0511ba8..fc41a97c9 100644 --- a/backend/app/api/menu.py +++ b/backend/app/api/menu.py @@ -6,76 +6,19 @@ from app.core.response import success_response router = APIRouter(prefix="/web/menu", tags=["菜单管理"]) -# 与 web-admin/src/router/modules/sqxp.ts 一致:Demo、系统管理、基础信息管理 等 +# 与 web-admin/src/router/modules/sqxp.ts 一致:工作台、系统管理、基础信息管理 等 STATIC_MENU_TREE = [ { - "id": "10000000000000000000000000000002", - "name": "Demo", - "path": "/demo", - "component": "/index/index", + "id": "10000000000000000000000000000001_child", + "name": "Console", + "path": "/console", + "component": "/dashboard/console/index", "meta": { - "title": "Demo", - "icon": "ri:layout-grid-line", - "keepAlive": False, - "roles": ["admin"], + "title": "工作台", + "keepAlive": True, + "fixedTab": True, + "menuCode": "dashboard", }, - "children": [ - { - "id": "10000000000000000000000000000001_child", - "name": "Console", - "path": "console", - "component": "/dashboard/console/index", - "meta": { - "title": "工作台", - "icon": "ri:home-smile-2-line", - "keepAlive": True, - "fixedTab": True, - "menuCode": "dashboard", - }, - }, - { - "id": "10000000000000000000000000000005", - "name": "Unit", - "path": "unit", - "component": "/system/unit/index", - "meta": {"title": "商品单位", "keepAlive": True}, - }, - { - "id": "10000000000000000000000000000021", - "name": "OrderList", - "path": "order", - "component": "/system/order/index", - "meta": {"title": "订单列表", "keepAlive": True}, - }, - { - "id": "10000000000000000000000000000006", - "name": "Product", - "path": "product", - "component": "/system/product/index", - "meta": {"title": "商品管理", "keepAlive": True}, - }, - { - "id": "10000000000000000000000000000004", - "name": "Category", - "path": "category", - "component": "/system/category/index", - "meta": {"title": "商品分类", "keepAlive": True}, - }, - { - "id": "10000000000000000000000000000007", - "name": "User", - "path": "user", - "component": "/system/user/index", - "meta": {"title": "用户管理", "keepAlive": True}, - }, - { - "id": "10000000000000000000000000000008", - "name": "UserDetail", - "path": "user/detail", - "component": "/system/user/detail", - "meta": {"title": "用户详情", "isHide": True, "keepAlive": True}, - }, - ], }, { "id": "100", @@ -234,7 +177,14 @@ STATIC_MENU_TREE = [ "name": "CellStorageProtocol", "path": "protocol", "component": "/cell-storage/protocol/index", - "meta": {"title": "采集协议单", "keepAlive": True}, + "meta": {"title": "采集协议单录入", "keepAlive": True}, + }, + { + "id": "50000000000000000000000000000003", + "name": "CellStorageProtocolAudit", + "path": "protocol-audit", + "component": "/cell-storage/protocol-audit/index", + "meta": {"title": "采集协议单管理", "keepAlive": True}, }, { "id": "50000000000000000000000000000007", @@ -243,13 +193,6 @@ STATIC_MENU_TREE = [ "component": "/cell-storage/payment-info/index", "meta": {"title": "缴费信息", "keepAlive": True}, }, - { - "id": "50000000000000000000000000000003", - "name": "CellStorageProtocolAudit", - "path": "protocol-audit", - "component": "/cell-storage/protocol-audit/index", - "meta": {"title": "采集协议单审核", "keepAlive": True}, - }, { "id": "50000000000000000000000000000004", "name": "CellStoragePaymentConfirm", diff --git a/backend/app/api/pilot_service.py b/backend/app/api/pilot_service.py index df7b48362..3b61497a1 100644 --- a/backend/app/api/pilot_service.py +++ b/backend/app/api/pilot_service.py @@ -665,7 +665,10 @@ async def create_channel( remark=body.remark, user_id=user.get("user_id"), ) - return success_response(ps_svc._row_to_dict_channel(obj), message="创建成功") + return success_response( + ps_svc._row_to_dict_channel(obj, db, tenant_no), + message="创建成功", + ) except ValueError as e: return error_response(400, str(e)) @@ -695,7 +698,10 @@ async def update_channel( user_id=user.get("user_id"), tenant_no=tenant_no, ) - return success_response(ps_svc._row_to_dict_channel(obj), message="更新成功") + return success_response( + ps_svc._row_to_dict_channel(obj, db, tenant_no), + message="更新成功", + ) except ValueError as e: return error_response(400, str(e)) diff --git a/backend/app/api/production.py b/backend/app/api/production.py index 7a1e088cd..75d55c9f9 100644 --- a/backend/app/api/production.py +++ b/backend/app/api/production.py @@ -38,6 +38,7 @@ class ConsumableCreateBody(BaseModel): consumable_name: str = Field(..., alias="consumableName") spec_model: Optional[str] = Field(None, alias="specModel") standard_usage: Optional[str] = Field(None, alias="standardUsage") + unit: Optional[str] = None description: Optional[str] = None order_num: Optional[int] = Field(0, alias="orderNum") remark: Optional[str] = None @@ -49,6 +50,7 @@ class ConsumableUpdateBody(BaseModel): consumable_name: Optional[str] = Field(None, alias="consumableName") spec_model: Optional[str] = Field(None, alias="specModel") standard_usage: Optional[str] = Field(None, alias="standardUsage") + unit: Optional[str] = None description: Optional[str] = None order_num: Optional[int] = Field(None, alias="orderNum") remark: Optional[str] = None @@ -92,6 +94,7 @@ async def create_consumable( consumable_name=body.consumable_name, spec_model=body.spec_model, standard_usage=body.standard_usage, + unit=body.unit, description=body.description, tenant_no=tenant_no, order_num=body.order_num, @@ -119,6 +122,7 @@ async def update_consumable( consumable_name=payload.get("consumable_name"), spec_model=payload.get("spec_model"), standard_usage=payload.get("standard_usage"), + unit=payload.get("unit"), description=payload.get("description"), order_num=payload.get("order_num"), remark=payload.get("remark"), @@ -329,7 +333,10 @@ async def create_sop_document( remark=body.remark, user_id=user.get("user_id"), ) - return success_response(sop_document_svc._row_to_dict(obj), message="创建成功") + return success_response( + sop_document_svc._row_to_dict(obj, db, tenant_no), + message="创建成功", + ) except ValueError as e: return error_response(400, str(e)) @@ -354,7 +361,11 @@ async def update_sop_document( remark=payload.get("remark"), user_id=user.get("user_id"), ) - return success_response(sop_document_svc._row_to_dict(obj), message="更新成功") + tenant_no = _get_tenant_no(db, user) + return success_response( + sop_document_svc._row_to_dict(obj, db, tenant_no), + message="更新成功", + ) except ValueError as e: return error_response(400, str(e)) @@ -589,6 +600,7 @@ class PackItemInput(BaseModel): consumable_name: Optional[str] = Field(None, alias="consumableName") spec_model: Optional[str] = Field(None, alias="specModel") standard_usage: Optional[str] = Field(None, alias="standardUsage") + unit: Optional[str] = None remark: Optional[str] = None @@ -598,7 +610,7 @@ class ConsumablePackCreateBody(BaseModel): pack_name: str = Field(..., alias="packName") usage_scene: Optional[str] = Field(None, alias="usageScene") pack_type: Optional[str] = Field(None, alias="packType") - status: Optional[str] = "1" + status: Optional[int] = 1 order_num: Optional[int] = Field(0, alias="orderNum") remark: Optional[str] = None items: List[PackItemInput] = [] @@ -610,7 +622,7 @@ class ConsumablePackUpdateBody(BaseModel): pack_name: Optional[str] = Field(None, alias="packName") usage_scene: Optional[str] = Field(None, alias="usageScene") pack_type: Optional[str] = Field(None, alias="packType") - status: Optional[str] = None + status: Optional[int] = None order_num: Optional[int] = Field(None, alias="orderNum") remark: Optional[str] = None items: Optional[List[PackItemInput]] = None @@ -667,7 +679,7 @@ async def create_consumable_pack( pack_name=body.pack_name, usage_scene=body.usage_scene, pack_type=body.pack_type, - status=int(body.status) if body.status is not None else None, + status=body.status, items=items_data, tenant_no=tenant_no, order_num=body.order_num, @@ -696,7 +708,7 @@ async def update_consumable_pack( pack_name=payload.get("pack_name"), usage_scene=payload.get("usage_scene"), pack_type=payload.get("pack_type"), - status=int(payload["status"]) if "status" in payload else None, + status=payload.get("status"), items=items_data, order_num=payload.get("order_num"), remark=payload.get("remark"), @@ -733,6 +745,7 @@ class ProcessReagentInput(BaseModel): standard_usage: Optional[str] = Field(None, alias="standardUsage") unit: Optional[str] = None is_key_material: Optional[str] = Field("0", alias="isKeyMaterial") + remark: Optional[str] = None class ProcessConsumableInput(BaseModel): @@ -741,6 +754,7 @@ class ProcessConsumableInput(BaseModel): consumable_name: Optional[str] = Field(None, alias="consumableName") spec_model: Optional[str] = Field(None, alias="specModel") standard_usage: Optional[str] = Field(None, alias="standardUsage") + unit: Optional[str] = None is_required: Optional[str] = Field("1", alias="isRequired") remark: Optional[str] = None @@ -750,6 +764,15 @@ class ProcessOutputInput(BaseModel): output_type_id: Optional[str] = Field(None, alias="outputTypeId") output_name: Optional[str] = Field(None, alias="outputName") output_barcode: Optional[str] = Field(None, alias="outputBarcode") + remark: Optional[str] = None + + +class ProcessDataCollectionInput(BaseModel): + model_config = ConfigDict(populate_by_name=True) + collection_name: str = Field(..., alias="collectionName") + unit: Optional[str] = None + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None class ProcessCreateBody(BaseModel): @@ -766,6 +789,7 @@ class ProcessCreateBody(BaseModel): reagents: List[ProcessReagentInput] = [] consumables: List[ProcessConsumableInput] = [] outputs: List[ProcessOutputInput] = [] + data_collections: List[ProcessDataCollectionInput] = Field([], alias="dataCollections") class ProcessUpdateBody(BaseModel): @@ -782,6 +806,7 @@ class ProcessUpdateBody(BaseModel): reagents: Optional[List[ProcessReagentInput]] = None consumables: Optional[List[ProcessConsumableInput]] = None outputs: Optional[List[ProcessOutputInput]] = None + data_collections: Optional[List[ProcessDataCollectionInput]] = Field(None, alias="dataCollections") @router.get("/processes") @@ -831,6 +856,7 @@ async def create_process( reagents_data = [r.model_dump(by_alias=False) for r in body.reagents] if body.reagents else None consumables_data = [c.model_dump(by_alias=False) for c in body.consumables] if body.consumables else None outputs_data = [o.model_dump(by_alias=False) for o in body.outputs] if body.outputs else None + data_collections_data = [d.model_dump(by_alias=False) for d in body.data_collections] if body.data_collections else None # Convert string fields to appropriate types for sub-records for rd in (reagents_data or []): if "is_key_material" in rd and isinstance(rd["is_key_material"], str): @@ -850,6 +876,7 @@ async def create_process( reagents=reagents_data, consumables=consumables_data, outputs=outputs_data, + data_collections=data_collections_data, tenant_no=tenant_no, order_num=body.order_num, remark=body.remark, @@ -872,6 +899,7 @@ async def update_process( reagents_data = [r.model_dump(by_alias=False) for r in body.reagents] if body.reagents is not None else None consumables_data = [c.model_dump(by_alias=False) for c in body.consumables] if body.consumables is not None else None outputs_data = [o.model_dump(by_alias=False) for o in body.outputs] if body.outputs is not None else None + data_collections_data = [d.model_dump(by_alias=False) for d in body.data_collections] if body.data_collections is not None else None # Convert string fields to appropriate types for sub-records for rd in (reagents_data or []): if "is_key_material" in rd and isinstance(rd["is_key_material"], str): @@ -892,6 +920,7 @@ async def update_process( reagents=reagents_data, consumables=consumables_data, outputs=outputs_data, + data_collections=data_collections_data, order_num=payload.get("order_num"), remark=payload.get("remark"), user_id=user.get("user_id"), diff --git a/backend/app/api/research_donation.py b/backend/app/api/research_donation.py index 04255c7e7..7fb78f3de 100644 --- a/backend/app/api/research_donation.py +++ b/backend/app/api/research_donation.py @@ -46,11 +46,16 @@ class DonationCreateBody(BaseModel): source_type: Optional[str] = Field("direct", alias="sourceType") source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId") consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls") + consent_attachments: Optional[list] = Field(None, alias="consentAttachments") attachment_urls: Optional[str] = Field(None, alias="attachmentUrls") + other_attachments: Optional[list] = Field(None, alias="otherAttachments") remark: Optional[str] = None party: Optional[dict] = None + party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection: Optional[dict] = None + collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = None + collector_user_id: Optional[str] = Field(None, alias="collectorUserId") class DonationUpdateBody(BaseModel): @@ -62,11 +67,16 @@ class DonationUpdateBody(BaseModel): source_type: Optional[str] = Field(None, alias="sourceType") source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId") consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls") + consent_attachments: Optional[list] = Field(None, alias="consentAttachments") attachment_urls: Optional[str] = Field(None, alias="attachmentUrls") + other_attachments: Optional[list] = Field(None, alias="otherAttachments") remark: Optional[str] = None party: Optional[dict] = None + party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection: Optional[dict] = None + collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = None + collector_user_id: Optional[str] = Field(None, alias="collectorUserId") class BatchDeleteBody(BaseModel): @@ -125,7 +135,33 @@ async def list_research_donations( # --------------------------------------------------------------------------- -# 2. 详情 +# 2. 审核列表(须在 {donation_id} 详情路由之前注册,避免 audits 被当作 id) +# --------------------------------------------------------------------------- + + +@router.get("/research-donations/audits") +async def list_research_donation_audits( + page: int = Query(1, ge=1), + pageSize: int = Query(20, ge=1, le=200, alias="pageSize"), + keyword: Optional[str] = Query(None, alias="keyword"), + auditStatus: Optional[str] = Query(None, alias="auditStatus"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + result = rd_svc.list_audits( + db, + tenant_no=tenant_no, + page=page, + page_size=pageSize, + keyword=keyword, + audit_status=auditStatus, + ) + return success_response(result) + + +# --------------------------------------------------------------------------- +# 3. 详情 # --------------------------------------------------------------------------- @@ -143,7 +179,7 @@ async def get_research_donation_detail( # --------------------------------------------------------------------------- -# 3. 创建 +# 4. 创建 # --------------------------------------------------------------------------- @@ -164,13 +200,16 @@ async def create_research_donation( tenant_no=tenant_no, user_id=user_id, ) - return success_response(rd_svc._donation_to_dict(obj), message="创建成功") + return success_response( + rd_svc._donation_to_dict(obj, db, tenant_no), + message="创建成功", + ) except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- -# 4. 更新 +# 5. 更新 # --------------------------------------------------------------------------- @@ -192,13 +231,16 @@ async def update_research_donation( tenant_no=tenant_no, user_id=user_id, ) - return success_response(rd_svc._donation_to_dict(obj), message="更新成功") + return success_response( + rd_svc._donation_to_dict(obj, db, tenant_no), + message="更新成功", + ) except ValueError as e: return error_response(400, str(e)) # --------------------------------------------------------------------------- -# 5. 删除 +# 6. 删除 # --------------------------------------------------------------------------- @@ -223,7 +265,7 @@ async def delete_research_donation( # --------------------------------------------------------------------------- -# 6. 批量删除 +# 7. 批量删除 # --------------------------------------------------------------------------- @@ -247,32 +289,6 @@ async def batch_delete_research_donations( return error_response(400, str(e)) -# --------------------------------------------------------------------------- -# 7. 审核列表 -# --------------------------------------------------------------------------- - - -@router.get("/research-donations/audits") -async def list_research_donation_audits( - page: int = Query(1, ge=1), - pageSize: int = Query(20, ge=1, le=200, alias="pageSize"), - keyword: Optional[str] = Query(None, alias="keyword"), - auditStatus: Optional[str] = Query(None, alias="auditStatus"), - user: dict = Depends(require_admin_or_super_admin), - db: Session = Depends(get_db), -): - tenant_no = _get_tenant_no(db, user) - result = rd_svc.list_audits( - db, - tenant_no=tenant_no, - page=page, - page_size=pageSize, - keyword=keyword, - audit_status=auditStatus, - ) - return success_response(result) - - # --------------------------------------------------------------------------- # 8. 提交审核 # --------------------------------------------------------------------------- @@ -330,7 +346,10 @@ async def change_donation_status( user_id=user_id, tenant_no=tenant_no, ) - return success_response(rd_svc._donation_to_dict(obj), message="状态变更成功") + return success_response( + rd_svc._donation_to_dict(obj, db, tenant_no), + message="状态变更成功", + ) except ValueError as e: return error_response(400, str(e)) @@ -355,7 +374,10 @@ async def convert_from_storage( tenant_no=tenant_no, user_id=user_id, ) - return success_response(rd_svc._donation_to_dict(obj), message="转换成功") + return success_response( + rd_svc._donation_to_dict(obj, db, tenant_no), + message="转换成功", + ) except ValueError as e: return error_response(400, str(e)) @@ -380,6 +402,9 @@ async def convert_to_storage( tenant_no=tenant_no, user_id=user_id, ) - return success_response(rd_svc._donation_to_dict(obj), message="转换成功") + return success_response( + rd_svc._donation_to_dict(obj, db, tenant_no), + message="转换成功", + ) except ValueError as e: return error_response(400, str(e)) diff --git a/backend/app/core/storage.py b/backend/app/core/storage.py index e147c10e3..c0f07ddd2 100644 --- a/backend/app/core/storage.py +++ b/backend/app/core/storage.py @@ -22,7 +22,8 @@ import os import uuid import base64 from io import BytesIO -from datetime import datetime +from datetime import datetime, timedelta +from urllib.parse import quote from typing import Optional, BinaryIO from pathlib import Path @@ -76,6 +77,12 @@ class BaseStorage: """获取文件访问URL""" raise NotImplementedError + def presigned_download_url( + self, file_path: str, download_name: str, expires_seconds: int = 3600 + ) -> str: + """带下载文件名的访问 URL;默认实现为普通访问地址。""" + return self.get_url(file_path) + class LocalStorage(BaseStorage): """本地存储""" @@ -316,6 +323,40 @@ class MinIOStorage(BaseStorage): """获取文件访问URL""" return f"{self.base_url}/{file_path}" + def _extract_object_key(self, file_path: str) -> str: + u = (file_path or "").strip() + if not u: + return "" + if "?" in u: + u = u.split("?", 1)[0] + prefix = f"{self.base_url}/" + if u.startswith(prefix): + return u[len(prefix) :] + if u.startswith("http://") or u.startswith("https://"): + marker = f"/{self.bucket_name}/" + idx = u.find(marker) + if idx >= 0: + return u[idx + len(marker) :] + return u.lstrip("/") + + def presigned_download_url( + self, file_path: str, download_name: str, expires_seconds: int = 3600 + ) -> str: + """MinIO 预签名下载 URL,携带原始文件名。""" + key = self._extract_object_key(file_path) + if not key: + raise ValueError("无效的文件路径") + name = (download_name or "").strip() or key.rsplit("/", 1)[-1] or "download" + ascii_name = name.encode("ascii", "ignore").decode() or "download" + encoded = quote(name, safe="") + disposition = f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{encoded}' + return self.client.presigned_get_object( + bucket_name=self.bucket_name, + object_name=key, + expires=timedelta(seconds=expires_seconds), + response_headers={"response-content-disposition": disposition}, + ) + def get_storage() -> BaseStorage: """获取存储实例""" diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index c1db78efa..8c683b5b3 100644 --- a/backend/app/crud/collection_protocol.py +++ b/backend/app/crud/collection_protocol.py @@ -12,6 +12,11 @@ from sqlalchemy.orm import Session from app.core.utils import generate_uuid, parse_datetime from app.crud.base import camel_to_snake +from app.crud.protocol_donation_convert import ( + assert_biz_no_available, + get_partner_sample_codes, +) +from app.models.research_donation import BizResearchDonation, BizResearchDonationSample from app.models.models import ( BizAttachment, BizCollectionProtocol, @@ -35,11 +40,12 @@ def get_protocol_nos_used_in_protocols( tenant_no: str, protocol_nos: List[str], ) -> set: - """返回已在协议单主表中占用的协议编号集合(同租户、未删除)。""" + """返回已在采集协议单或科研捐赠单中占用的协议编号集合(同租户、未删除)。""" nos = list({(n or "").strip() for n in protocol_nos if (n or "").strip()}) if not nos: return set() - rows = ( + used: set = set() + proto_rows = ( db.query(BizCollectionProtocol.protocol_no) .filter( BizCollectionProtocol.tenant_no == tenant_no, @@ -48,7 +54,18 @@ def get_protocol_nos_used_in_protocols( ) .all() ) - return {r[0] for r in rows if r[0]} + used.update(r[0] for r in proto_rows if r[0]) + donation_rows = ( + db.query(BizResearchDonation.donation_no) + .filter( + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + BizResearchDonation.donation_no.in_(nos), + ) + .all() + ) + used.update(r[0] for r in donation_rows if r[0]) + return used def get_sample_codes_used_in_protocol_samples( @@ -57,11 +74,12 @@ def get_sample_codes_used_in_protocol_samples( tenant_no: str, sample_codes: List[str], ) -> set: - """返回已在协议单样本表中占用的样本编码集合(同租户、未删除)。""" + """返回已在采集协议单或科研捐赠单样本表中占用的样本编码集合(同租户、未删除)。""" codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()}) if not codes: return set() - rows = ( + used: set = set() + proto_rows = ( db.query(BizProtocolSample.sample_code) .filter( BizProtocolSample.tenant_no == tenant_no, @@ -70,7 +88,51 @@ def get_sample_codes_used_in_protocol_samples( ) .all() ) - return {r[0] for r in rows if r[0]} + used.update(r[0] for r in proto_rows if r[0]) + donation_rows = ( + db.query(BizResearchDonationSample.sample_code) + .filter( + BizResearchDonationSample.tenant_no == tenant_no, + BizResearchDonationSample.del_flag == 0, + BizResearchDonationSample.sample_code.in_(codes), + ) + .all() + ) + used.update(r[0] for r in donation_rows if r[0]) + return used + + +def validate_sample_codes_not_used( + db: Session, + *, + tenant_no: str, + samples: list, + exclude_protocol_id: Optional[str] = None, + exclude_donation_id: Optional[str] = None, +) -> None: + """创建/更新样本行前校验:样本编码不得已被任一业务单占用(转移配对单豁免)。""" + codes: List[str] = [] + for raw_item in samples or []: + raw = _normalize_fields(raw_item) + c = (raw.get("sample_code") or "").strip() + if c: + codes.append(c) + if not codes: + return + used = get_sample_codes_used_in_protocol_samples( + db, tenant_no=tenant_no, sample_codes=codes + ) + exempt = get_partner_sample_codes( + db, + tenant_no, + protocol_id=exclude_protocol_id, + donation_id=exclude_donation_id, + ) + used -= exempt + conflicts = set(codes) & used + if conflicts: + first = sorted(conflicts)[0] + raise ValueError(f"样本编码「{first}」已被占用") def total_pages(total: int, page_size: int) -> int: @@ -1360,21 +1422,7 @@ def create_collection_protocol( user_id: Optional[str] = None, ) -> BizCollectionProtocol: pn = (protocol_no or "").strip() - if not pn: - raise ValueError("协议编号不能为空") - dup = ( - db.query(BizCollectionProtocol) - .filter( - and_( - BizCollectionProtocol.protocol_no == pn, - BizCollectionProtocol.tenant_no == tenant_no, - BizCollectionProtocol.del_flag == 0, - ) - ) - .first() - ) - if dup: - raise ValueError("协议编号已存在") + assert_biz_no_available(db, tenant_no=tenant_no, biz_no=pn) obj = BizCollectionProtocol( id=generate_uuid(), protocol_no_id=(protocol_no_id or "").strip() or None, @@ -1431,20 +1479,12 @@ def update_collection_protocol( if not pn: raise ValueError("协议编号不能为空") if pn != obj.protocol_no: - dup = ( - db.query(BizCollectionProtocol) - .filter( - and_( - BizCollectionProtocol.protocol_no == pn, - BizCollectionProtocol.tenant_no == tenant_no, - BizCollectionProtocol.del_flag == 0, - BizCollectionProtocol.id != protocol_id, - ) - ) - .first() + assert_biz_no_available( + db, + tenant_no=tenant_no, + biz_no=pn, + exclude_protocol_id=protocol_id, ) - if dup: - raise ValueError("协议编号已存在") obj.protocol_no = pn if protocol_status is not None: obj.protocol_status = protocol_status.strip() @@ -1684,6 +1724,12 @@ def save_samples( db.add(s) if existing: db.flush() + validate_sample_codes_not_used( + db, + tenant_no=tenant_no, + samples=samples, + exclude_protocol_id=collection_protocol_id, + ) _STR_FIELDS = [ "sample_type_id", "sample_code_id", "sample_code", "unit_id", "sample_status", "storage_in_date", "detect_start_date", diff --git a/backend/app/crud/consumable.py b/backend/app/crud/consumable.py index 21111410b..6eb365535 100644 --- a/backend/app/crud/consumable.py +++ b/backend/app/crud/consumable.py @@ -19,6 +19,7 @@ def _row_to_dict(c: BizConsumable) -> Dict[str, Any]: "consumableName": c.consumable_name, "specModel": c.spec_model or "", "standardUsage": c.standard_usage or "", + "unit": c.unit or "", "description": c.description or "", "tenantNo": c.tenant_no, "orderNum": c.order_num if c.order_num is not None else 0, @@ -88,6 +89,7 @@ def create_consumable( consumable_name: str, spec_model: Optional[str] = None, standard_usage: Optional[str] = None, + unit: Optional[str] = None, description: Optional[str] = None, tenant_no: str = "zkxw", order_num: Optional[int] = None, @@ -105,6 +107,7 @@ def create_consumable( consumable_name=(consumable_name or "").strip() or cc, spec_model=(spec_model or "").strip() or None, standard_usage=(standard_usage or "").strip() or None, + unit=(unit or "").strip() or None, description=(description or "").strip() or None, tenant_no=tenant_no, order_num=order_num if order_num is not None else 0, @@ -130,6 +133,7 @@ def update_consumable( consumable_name: Optional[str] = None, spec_model: Optional[str] = None, standard_usage: Optional[str] = None, + unit: Optional[str] = None, description: Optional[str] = None, order_num: Optional[int] = None, remark: Optional[str] = None, @@ -151,6 +155,8 @@ def update_consumable( obj.spec_model = (spec_model or "").strip() or None if standard_usage is not None: obj.standard_usage = (standard_usage or "").strip() or None + if unit is not None: + obj.unit = (unit or "").strip() or None if description is not None: obj.description = (description or "").strip() or None if order_num is not None: diff --git a/backend/app/crud/consumable_pack.py b/backend/app/crud/consumable_pack.py index 96b366fd0..212365261 100644 --- a/backend/app/crud/consumable_pack.py +++ b/backend/app/crud/consumable_pack.py @@ -20,6 +20,7 @@ def _item_row_to_dict(item: BizConsumablePackItem) -> Dict[str, Any]: "consumableName": item.consumable_name, "specModel": item.spec_model or "", "standardUsage": item.standard_usage or "", + "unit": item.unit or "", "remark": item.remark or "", } @@ -31,7 +32,7 @@ def _row_to_dict(p: BizConsumablePack) -> Dict[str, Any]: "packName": p.pack_name, "usageScene": p.usage_scene or "", "packType": p.pack_type or "", - "status": p.status if p.status is not None else 0, + "status": int(p.status) if p.status not in (None, "") else 0, "tenantNo": p.tenant_no, "orderNum": p.order_num if p.order_num is not None else 0, "remark": p.remark or "", @@ -144,7 +145,7 @@ def create_consumable_pack( pack_name=(pack_name or "").strip() or pc, usage_scene=(usage_scene or "").strip() or None, pack_type=(pack_type or "").strip() or None, - status=status if status is not None else 0, + status=str(status) if status is not None else "1", tenant_no=tenant_no, order_num=order_num if order_num is not None else 0, remark=(remark or "").strip() or None, @@ -167,6 +168,7 @@ def create_consumable_pack( consumable_name=(it.get("consumable_name") or "").strip(), spec_model=(it.get("spec_model") or "").strip() or None, standard_usage=(it.get("standard_usage") or "").strip() or None, + unit=(it.get("unit") or "").strip() or None, remark=(it.get("remark") or "").strip() or None, tenant_no=tenant_no, order_num=0, @@ -215,7 +217,7 @@ def update_consumable_pack( if pack_type is not None: obj.pack_type = (pack_type or "").strip() or None if status is not None: - obj.status = status + obj.status = str(status) if order_num is not None: obj.order_num = order_num if remark is not None: @@ -244,6 +246,7 @@ def update_consumable_pack( consumable_name=(it.get("consumable_name") or "").strip(), spec_model=(it.get("spec_model") or "").strip() or None, standard_usage=(it.get("standard_usage") or "").strip() or None, + unit=(it.get("unit") or "").strip() or None, remark=(it.get("remark") or "").strip() or None, tenant_no=obj.tenant_no, order_num=0, diff --git a/backend/app/crud/pilot_service.py b/backend/app/crud/pilot_service.py index 01221c5f0..f8ecbe006 100644 --- a/backend/app/crud/pilot_service.py +++ b/backend/app/crud/pilot_service.py @@ -9,6 +9,7 @@ from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid +from app.crud.collection_protocol import urls_to_attachment_list from app.models.models import ( BizCellProductType, BizCellProductSpec, @@ -698,7 +699,12 @@ def soft_delete_project_system_item(db, item_id, tenant_no=None): # Entity 6: BizChannel 渠道信息 # ──────────────────────────────────────────── -def _row_to_dict_channel(row) -> Dict[str, Any]: +def _row_to_dict_channel( + row, + db: Optional[Session] = None, + tenant_no: Optional[str] = None, +) -> Dict[str, Any]: + tn = tenant_no or row.tenant_no return { "id": row.id, "channelName": row.channel_name or "", @@ -708,6 +714,7 @@ def _row_to_dict_channel(row) -> Dict[str, Any]: "contactPhone": row.contact_phone or "", "contactEmail": row.contact_email or "", "protocolAttachments": row.protocol_attachments or "", + "attachmentFiles": urls_to_attachment_list(row.protocol_attachments, db, tn) if db else [], "tenantNo": row.tenant_no or "", "orderNum": row.order_num if row.order_num is not None else 0, "remark": row.remark or "", @@ -737,7 +744,7 @@ def list_channels(db, *, page=1, page_size=10, keyword=None, tenant_no=None): page = max(1, page) page_size = min(max(1, page_size), 100) rows = _filtered().order_by(BizChannel.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() - return [_row_to_dict_channel(x) for x in rows], int(total) + return [_row_to_dict_channel(x, db, tenant_no) for x in rows], int(total) def get_channel(db, channel_id, tenant_no=None): @@ -1305,6 +1312,60 @@ def _row_to_dict_channel_agreement_price(row) -> Dict[str, Any]: } +def _channel_agreement_price_item_key( + project_code=None, + project_name=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, +) -> str: + code = (project_code or project_name or "").strip().lower() + cell = (cell_type_code or cell_type_name or "").strip().lower() + spec = (spec_text or "").strip().lower() + return f"{code}|{cell}|{spec}" + + +def _channel_agreement_price_exists( + db, + channel_agreement_id, + *, + project_code=None, + project_name=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, + tenant_no=None, + exclude_price_id=None, +): + target_key = _channel_agreement_price_item_key( + project_code=project_code, + project_name=project_name, + cell_type_code=cell_type_code, + cell_type_name=cell_type_name, + spec_text=spec_text, + ) + q = db.query(BizChannelAgreementProductPrice).filter( + and_( + BizChannelAgreementProductPrice.channel_agreement_id == channel_agreement_id, + BizChannelAgreementProductPrice.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizChannelAgreementProductPrice.tenant_no == tenant_no) + if exclude_price_id: + q = q.filter(BizChannelAgreementProductPrice.id != exclude_price_id) + for row in q.all(): + if _channel_agreement_price_item_key( + project_code=row.project_code, + project_name=row.project_name, + cell_type_code=row.cell_type_code, + cell_type_name=row.cell_type_name, + spec_text=row.spec_text, + ) == target_key: + return True + return False + + def list_channel_agreement_prices(db, channel_agreement_id, tenant_no=None): q = db.query(BizChannelAgreementProductPrice).filter( and_( @@ -1334,6 +1395,17 @@ def create_channel_agreement_price( remark=None, user_id=None, ): + if _channel_agreement_price_exists( + db, + channel_agreement_id, + project_code=project_code, + project_name=project_name, + cell_type_code=cell_type_code, + cell_type_name=cell_type_name, + spec_text=spec_text, + tenant_no=tenant_no, + ): + raise ValueError("该项目信息已存在,不可重复添加") obj = BizChannelAgreementProductPrice( id=generate_uuid(), channel_agreement_id=channel_agreement_id, @@ -1383,6 +1455,23 @@ def update_channel_agreement_price( obj = q.first() if not obj: raise ValueError("记录不存在") + next_project_name = project_name if project_name is not None else obj.project_name + next_project_code = project_code if project_code is not None else obj.project_code + next_cell_type_code = cell_type_code if cell_type_code is not None else obj.cell_type_code + next_cell_type_name = cell_type_name if cell_type_name is not None else obj.cell_type_name + next_spec_text = spec_text if spec_text is not None else obj.spec_text + if _channel_agreement_price_exists( + db, + obj.channel_agreement_id, + project_code=next_project_code, + project_name=next_project_name, + cell_type_code=next_cell_type_code, + cell_type_name=next_cell_type_name, + spec_text=next_spec_text, + tenant_no=tenant_no, + exclude_price_id=price_id, + ): + raise ValueError("该项目信息已存在,不可重复添加") if project_name is not None: obj.project_name = project_name if project_code is not None: diff --git a/backend/app/crud/process.py b/backend/app/crud/process.py index d02378d9d..921bd2576 100644 --- a/backend/app/crud/process.py +++ b/backend/app/crud/process.py @@ -12,6 +12,7 @@ from app.core.utils import generate_uuid from app.models.models import ( BizProcess, BizProcessConsumable, + BizProcessDataCollection, BizProcessOutput, BizProcessReagent, ) @@ -27,6 +28,7 @@ def _reagent_row_to_dict(r: BizProcessReagent) -> Dict[str, Any]: "standardUsage": r.standard_usage or "", "unit": r.unit or "", "isKeyMaterial": r.is_key_material if r.is_key_material is not None else 0, + "remark": r.remark or "", } @@ -38,6 +40,7 @@ def _consumable_row_to_dict(c: BizProcessConsumable) -> Dict[str, Any]: "consumableName": c.consumable_name, "specModel": c.spec_model or "", "standardUsage": c.standard_usage or "", + "unit": c.unit or "", "isRequired": c.is_required if c.is_required is not None else 0, "remark": c.remark or "", } @@ -53,6 +56,17 @@ def _output_row_to_dict(o: BizProcessOutput) -> Dict[str, Any]: } +def _data_collection_row_to_dict(d: BizProcessDataCollection) -> Dict[str, Any]: + return { + "id": d.id, + "processId": d.process_id, + "collectionName": d.collection_name or "", + "unit": d.unit or "", + "orderNum": d.order_num if d.order_num is not None else 0, + "remark": d.remark or "", + } + + def _row_to_dict(p: BizProcess) -> Dict[str, Any]: return { "id": p.id, @@ -153,6 +167,15 @@ def get_process_detail(db: Session, process_id: str) -> Optional[Dict[str, Any]] result["reagents"] = [_reagent_row_to_dict(r) for r in reagents] result["consumables"] = [_consumable_row_to_dict(c) for c in consumables] result["outputs"] = [_output_row_to_dict(o) for o in outputs] + + data_collections = ( + db.query(BizProcessDataCollection) + .filter(and_(BizProcessDataCollection.process_id == process_id, BizProcessDataCollection.del_flag == 0)) + .order_by(BizProcessDataCollection.order_num.asc(), BizProcessDataCollection.create_time.asc()) + .all() + ) + result["dataCollections"] = [_data_collection_row_to_dict(d) for d in data_collections] + return result @@ -195,6 +218,7 @@ def _create_sub_consumable(db: Session, process_id: str, tenant_no: str, user_id consumable_name=(data.get("consumable_name") or "").strip(), spec_model=(data.get("spec_model") or "").strip() or None, standard_usage=(data.get("standard_usage") or "").strip() or None, + unit=(data.get("unit") or "").strip() or None, is_required=data.get("is_required", 0), remark=(data.get("remark") or "").strip() or None, tenant_no=tenant_no, @@ -216,6 +240,7 @@ def _create_sub_output(db: Session, process_id: str, tenant_no: str, user_id: Op output_type_id=(data.get("output_type_id") or "").strip() or None, output_name=(data.get("output_name") or "").strip() or None, output_barcode=(data.get("output_barcode") or "").strip() or None, + remark=(data.get("remark") or "").strip() or None, tenant_no=tenant_no, order_num=0, create_by=user_id, @@ -228,6 +253,25 @@ def _create_sub_output(db: Session, process_id: str, tenant_no: str, user_id: Op db.add(obj) +def _create_sub_data_collection(db: Session, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime, data: Dict[str, Any]) -> None: + obj = BizProcessDataCollection( + id=generate_uuid(), + process_id=process_id, + collection_name=(data.get("collection_name") or "").strip(), + unit=(data.get("unit") or "").strip() or None, + tenant_no=tenant_no, + order_num=data.get("order_num", 0) or 0, + remark=(data.get("remark") or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + + def create_process( db: Session, *, @@ -241,6 +285,7 @@ def create_process( reagents: Optional[List[Dict[str, Any]]] = None, consumables: Optional[List[Dict[str, Any]]] = None, outputs: Optional[List[Dict[str, Any]]] = None, + data_collections: Optional[List[Dict[str, Any]]] = None, tenant_no: str = "zkxw", order_num: Optional[int] = None, remark: Optional[str] = None, @@ -283,6 +328,9 @@ def create_process( if outputs: for o in outputs: _create_sub_output(db, obj.id, tenant_no, user_id, now, o) + if data_collections: + for d in data_collections: + _create_sub_data_collection(db, obj.id, tenant_no, user_id, now, d) db.commit() db.refresh(obj) @@ -315,6 +363,7 @@ def update_process( reagents: Optional[List[Dict[str, Any]]] = None, consumables: Optional[List[Dict[str, Any]]] = None, outputs: Optional[List[Dict[str, Any]]] = None, + data_collections: Optional[List[Dict[str, Any]]] = None, order_num: Optional[int] = None, remark: Optional[str] = None, user_id: Optional[str] = None, @@ -349,7 +398,7 @@ def update_process( obj.update_time = datetime.now() db.add(obj) - sub_updated = reagents is not None or consumables is not None or outputs is not None + sub_updated = reagents is not None or consumables is not None or outputs is not None or data_collections is not None if sub_updated: now = datetime.now() if reagents is not None: @@ -364,6 +413,10 @@ def update_process( _soft_delete_sub_records(db, BizProcessOutput, process_id, now) for o in outputs: _create_sub_output(db, process_id, obj.tenant_no, user_id, now, o) + if data_collections is not None: + _soft_delete_sub_records(db, BizProcessDataCollection, process_id, now) + for d in data_collections: + _create_sub_data_collection(db, process_id, obj.tenant_no, user_id, now, d) db.commit() db.refresh(obj) @@ -382,6 +435,7 @@ def soft_delete_process(db: Session, process_id: str) -> None: _soft_delete_sub_records(db, BizProcessReagent, process_id, now) _soft_delete_sub_records(db, BizProcessConsumable, process_id, now) _soft_delete_sub_records(db, BizProcessOutput, process_id, now) + _soft_delete_sub_records(db, BizProcessDataCollection, process_id, now) db.commit() diff --git a/backend/app/crud/protocol_donation_convert.py b/backend/app/crud/protocol_donation_convert.py new file mode 100644 index 000000000..071d5adf8 --- /dev/null +++ b/backend/app/crud/protocol_donation_convert.py @@ -0,0 +1,311 @@ +"""细胞存储协议 ⇄ 科研捐赠 转移关联与编号/样本编码豁免校验""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional, Set + +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.core.utils import generate_uuid +from app.models.models import BizCollectionProtocol, BizProtocolSample +from app.models.research_donation import ( + BizProtocolDonationConvert, + BizResearchDonation, + BizResearchDonationSample, +) + + +def _active_link_query(db: Session, tenant_no: str): + return db.query(BizProtocolDonationConvert).filter( + and_( + BizProtocolDonationConvert.tenant_no == tenant_no, + BizProtocolDonationConvert.del_flag == 0, + ) + ) + + +def get_convert_partner( + db: Session, + tenant_no: str, + *, + protocol_id: Optional[str] = None, + donation_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """按采集协议或科研捐赠 ID 查询转移配对信息。""" + if protocol_id: + row = ( + _active_link_query(db, tenant_no) + .filter(BizProtocolDonationConvert.protocol_id == protocol_id) + .first() + ) + elif donation_id: + row = ( + _active_link_query(db, tenant_no) + .filter(BizProtocolDonationConvert.research_donation_id == donation_id) + .first() + ) + else: + return None + if not row: + return None + return { + "id": row.id, + "convert_type": row.convert_type, + "protocol_id": row.protocol_id, + "research_donation_id": row.research_donation_id, + "biz_no": row.biz_no or "", + } + + +def has_active_convert_link( + db: Session, + tenant_no: str, + *, + protocol_id: Optional[str] = None, + donation_id: Optional[str] = None, +) -> bool: + return get_convert_partner( + db, tenant_no, protocol_id=protocol_id, donation_id=donation_id + ) is not None + + +def create_convert_link( + db: Session, + *, + convert_type: str, + protocol_id: str, + research_donation_id: str, + biz_no: str, + tenant_no: str, + user_id: Optional[str] = None, + now: Optional[datetime] = None, +) -> BizProtocolDonationConvert: + """转移事务内写入关联;同一协议或捐赠不得重复建立有效关联。""" + now = now or datetime.now() + pid = (protocol_id or "").strip() + did = (research_donation_id or "").strip() + if not pid or not did: + raise ValueError("转移关联参数不完整") + + if ( + _active_link_query(db, tenant_no) + .filter(BizProtocolDonationConvert.protocol_id == pid) + .first() + ): + raise ValueError("该采集协议已存在转移关联") + if ( + _active_link_query(db, tenant_no) + .filter(BizProtocolDonationConvert.research_donation_id == did) + .first() + ): + raise ValueError("该科研捐赠已存在转移关联") + + obj = BizProtocolDonationConvert( + id=generate_uuid(), + convert_type=(convert_type or "").strip(), + protocol_id=pid, + research_donation_id=did, + biz_no=(biz_no or "").strip(), + tenant_no=tenant_no, + order_num=0, + remark=None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + db.flush() + return obj + + +def get_partner_sample_codes( + db: Session, + tenant_no: str, + *, + protocol_id: Optional[str] = None, + donation_id: Optional[str] = None, +) -> Set[str]: + """返回配对单据上未删除样本的编码集合(用于样本唯一性豁免)。""" + partner = get_convert_partner( + db, tenant_no, protocol_id=protocol_id, donation_id=donation_id + ) + if not partner: + return set() + codes: Set[str] = set() + protocol_ids: List[str] = [] + donation_ids: List[str] = [] + if partner.get("protocol_id"): + protocol_ids.append(partner["protocol_id"]) + if partner.get("research_donation_id"): + donation_ids.append(partner["research_donation_id"]) + don = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == partner["research_donation_id"], + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if don and (don.source_protocol_id or "").strip(): + spid = don.source_protocol_id.strip() + if spid not in protocol_ids: + protocol_ids.append(spid) + for pid in protocol_ids: + rows = ( + db.query(BizProtocolSample.sample_code) + .filter( + and_( + BizProtocolSample.collection_protocol_id == pid, + BizProtocolSample.tenant_no == tenant_no, + BizProtocolSample.del_flag == 0, + ) + ) + .all() + ) + codes.update(r[0] for r in rows if r[0]) + for did in donation_ids: + rows = ( + db.query(BizResearchDonationSample.sample_code) + .filter( + and_( + BizResearchDonationSample.research_donation_id == did, + BizResearchDonationSample.tenant_no == tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + ) + .all() + ) + codes.update(r[0] for r in rows if r[0]) + return codes + + +def assert_biz_no_available( + db: Session, + *, + tenant_no: str, + biz_no: str, + exclude_protocol_id: Optional[str] = None, + exclude_donation_id: Optional[str] = None, + for_donation: bool = False, +) -> None: + """ + 校验业务编号在采集协议与科研捐赠间可用。 + 若当前单存在转移配对,则豁免配对单据上的编号占用。 + """ + pn = (biz_no or "").strip() + if not pn: + raise ValueError("协议编号不能为空") + + partner = None + exempt_protocol_id: Optional[str] = None + exempt_donation_id: Optional[str] = None + if exclude_protocol_id: + partner = get_convert_partner( + db, tenant_no, protocol_id=exclude_protocol_id + ) + if partner: + exempt_donation_id = partner["research_donation_id"] + elif exclude_donation_id: + partner = get_convert_partner( + db, tenant_no, donation_id=exclude_donation_id + ) + if partner: + exempt_protocol_id = partner["protocol_id"] + + proto_rows = ( + db.query(BizCollectionProtocol) + .filter( + and_( + BizCollectionProtocol.protocol_no == pn, + BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0, + ) + ) + .all() + ) + for row in proto_rows: + if exclude_protocol_id and row.id == exclude_protocol_id: + continue + if exempt_protocol_id and row.id == exempt_protocol_id: + continue + if for_donation: + raise ValueError("捐赠协议号已被采集协议单使用") + raise ValueError("协议编号已存在") + + donation_rows = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.donation_no == pn, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .all() + ) + for row in donation_rows: + if exclude_donation_id and row.id == exclude_donation_id: + continue + if exempt_donation_id and row.id == exempt_donation_id: + continue + if for_donation: + raise ValueError("捐赠协议号已存在") + raise ValueError("协议编号已被捐赠协议单使用") + + +def upsert_convert_link_for_to_storage( + db: Session, + *, + protocol_id: str, + research_donation_id: str, + biz_no: str, + tenant_no: str, + user_id: Optional[str] = None, + now: Optional[datetime] = None, +) -> BizProtocolDonationConvert: + """ + 转细胞存储时写入/更新关联。 + 若捐赠单已有关联(如先转科研),则更新 protocol_id 指向新协议单。 + """ + now = now or datetime.now() + did = (research_donation_id or "").strip() + pid = (protocol_id or "").strip() + existing = ( + _active_link_query(db, tenant_no) + .filter(BizProtocolDonationConvert.research_donation_id == did) + .first() + ) + if existing: + if ( + _active_link_query(db, tenant_no) + .filter( + BizProtocolDonationConvert.protocol_id == pid, + BizProtocolDonationConvert.id != existing.id, + ) + .first() + ): + raise ValueError("该采集协议已存在转移关联") + existing.protocol_id = pid + existing.convert_type = "research_to_storage" + existing.update_by = user_id + existing.update_time = now + db.add(existing) + db.flush() + return existing + return create_convert_link( + db, + convert_type="research_to_storage", + protocol_id=pid, + research_donation_id=did, + biz_no=biz_no, + tenant_no=tenant_no, + user_id=user_id, + now=now, + ) diff --git a/backend/app/crud/qc_task.py b/backend/app/crud/qc_task.py index 345750e8a..1d5d32299 100644 --- a/backend/app/crud/qc_task.py +++ b/backend/app/crud/qc_task.py @@ -27,7 +27,13 @@ def _gen_task_no(db: Session, work_order: BizQcWorkOrder, seq: int) -> str: return f"{work_order.work_order_no}-{seq:02d}" -def _task_to_dict(t: BizQcTask, work_order_no: str = "") -> Dict[str, Any]: +def _task_to_dict( + t: BizQcTask, + work_order_no: str = "", + db: Optional[Session] = None, + tenant_no: Optional[str] = None, +) -> Dict[str, Any]: + tn = tenant_no or t.tenant_no return { "id": t.id, "workOrderId": t.work_order_id, @@ -42,6 +48,7 @@ def _task_to_dict(t: BizQcTask, work_order_no: str = "") -> Dict[str, Any]: "feeAmount": _dec(t.fee_amount), "taskStatus": t.task_status, "thirdPartyAttachments": t.third_party_attachments or "", + "attachmentFiles": urls_to_attachment_list(t.third_party_attachments, db, tn) if db else [], "completedTime": _fmt_dt(t.completed_time), "completedBy": t.completed_by or "", "remark": t.remark or "", @@ -151,7 +158,7 @@ def list_tasks( .limit(page_size) .all() ) - return [_task_to_dict(t, wno or "") for t, wno in rows], int(total) + return [_task_to_dict(t, wno or "", db, tenant_no) for t, wno in rows], int(total) def list_tasks_by_work_order(db: Session, work_order_id: str, tenant_no: str) -> List[Dict[str, Any]]: @@ -166,7 +173,7 @@ def list_tasks_by_work_order(db: Session, work_order_id: str, tenant_no: str) -> .order_by(BizQcTask.order_num.asc()) .all() ) - return [_task_to_dict(t, wno or "") for t, wno in rows] + return [_task_to_dict(t, wno or "", db, tenant_no) for t, wno in rows] def get_task( @@ -188,10 +195,9 @@ def get_task_detail(db: Session, row_id: str, tenant_no: str) -> Dict[str, Any]: if not row: raise ValueError("任务不存在") t, wo = row - out = _task_to_dict(t, wo.work_order_no or "") + out = _task_to_dict(t, wo.work_order_no or "", db, tenant_no) out["protocolNo"] = wo.protocol_no or "" out["sampleCode"] = wo.sample_code or "" - out["attachmentFiles"] = urls_to_attachment_list(t.third_party_attachments, db, tenant_no) return out diff --git a/backend/app/crud/research_donation.py b/backend/app/crud/research_donation.py index 031b93292..9348333ba 100644 --- a/backend/app/crud/research_donation.py +++ b/backend/app/crud/research_donation.py @@ -11,6 +11,18 @@ from sqlalchemy.orm import Session from app.core.utils import generate_uuid, parse_datetime from app.crud.base import camel_to_snake +from app.crud.collection_protocol import ( + attachments_to_urls, + urls_to_attachment_list, + validate_sample_codes_not_used, +) +from app.crud.protocol_donation_convert import ( + assert_biz_no_available, + create_convert_link, + get_convert_partner, + has_active_convert_link, + upsert_convert_link_for_to_storage, +) from app.models.models import ( BizCollectionProtocol, BizCollectionRegion, @@ -77,6 +89,40 @@ def _normalize_fields(fields: Optional[dict]) -> dict: return out +def _merge_comma_separated_urls(*parts: Optional[str]) -> Optional[str]: + """合并逗号分隔的附件 URL,去重并保持顺序。""" + seen: set[str] = set() + urls: List[str] = [] + for part in parts: + if not part: + continue + for url in str(part).split(","): + u = url.strip() + if not u or u in seen: + continue + seen.add(u) + urls.append(u) + return ",".join(urls) if urls else None + + +def _resolve_donation_payload(raw: dict) -> None: + """解析保存载荷:附件列表 → URL 字段;party/collection 别名。""" + if raw.get("consent_attachments") is not None: + raw["consent_form_urls"] = attachments_to_urls(raw["consent_attachments"]) or None + elif (raw.get("consent_form_urls") or "").strip(): + raw["consent_form_urls"] = (raw["consent_form_urls"] or "").strip() + + if raw.get("other_attachments") is not None: + raw["attachment_urls"] = attachments_to_urls(raw["other_attachments"]) or None + elif (raw.get("attachment_urls") or "").strip(): + raw["attachment_urls"] = (raw["attachment_urls"] or "").strip() + + if not raw.get("party") and raw.get("party_customer"): + raw["party"] = raw["party_customer"] + if not raw.get("collection") and raw.get("collection_info"): + raw["collection"] = raw["collection_info"] + + def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: ids = list({x for x in user_ids if x}) if not ids: @@ -90,7 +136,12 @@ def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: # --------------------------------------------------------------------------- -def _donation_to_dict(d: BizResearchDonation) -> Dict[str, Any]: +def _donation_to_dict( + d: BizResearchDonation, + db: Optional[Session] = None, + tenant_no: Optional[str] = None, +) -> Dict[str, Any]: + tn = tenant_no or d.tenant_no return { "id": d.id, "donationNo": d.donation_no or "", @@ -100,6 +151,8 @@ def _donation_to_dict(d: BizResearchDonation) -> Dict[str, Any]: "sourceProtocolId": d.source_protocol_id or "", "consentFormUrls": d.consent_form_urls or "", "attachmentUrls": d.attachment_urls or "", + "consentAttachmentFiles": urls_to_attachment_list(d.consent_form_urls, db, tn) if db else [], + "attachmentFiles": urls_to_attachment_list(d.attachment_urls, db, tn) if db else [], "collectorUserId": d.collector_user_id or "", "effectiveTime": _fmt_dt(d.effective_time), "effectiveDesc": d.effective_desc or "", @@ -372,9 +425,10 @@ def list_donations( .all() ) - # 批量加载 party + # 批量加载 party / collection 子表,避免 N+1 donation_ids = [r.id for r in rows] party_map: Dict[str, Dict[str, Any]] = {} + collection_map: Dict[str, Dict[str, Any]] = {} if donation_ids: party_rows = ( db.query(BizResearchDonationParty) @@ -387,21 +441,80 @@ def list_donations( for p in party_rows: party_map[p.research_donation_id] = _party_to_dict(p) - # 批量解析采集专员名称 + collection_rows = ( + db.query(BizResearchDonationCollection) + .filter( + BizResearchDonationCollection.research_donation_id.in_(donation_ids), + BizResearchDonationCollection.del_flag == 0, + ) + .all() + ) + for c in collection_rows: + collection_map[c.research_donation_id] = _collection_to_dict(c) + + # 批量解析地区 / 医院名称 + region_ids = list( + {collection_map.get(did, {}).get("regionId", "") for did in donation_ids} - {""} + ) + hospital_ids = list( + {collection_map.get(did, {}).get("hospitalId", "") for did in donation_ids} - {""} + ) + region_name_map: Dict[str, str] = {} + hospital_name_map: Dict[str, str] = {} + if region_ids: + for reg in ( + db.query(BizCollectionRegion.id, BizCollectionRegion.region_name) + .filter(BizCollectionRegion.id.in_(region_ids)) + .all() + ): + region_name_map[reg.id] = reg.region_name or "" + if hospital_ids: + for hosp in ( + db.query(BizCollectionHospital.id, BizCollectionHospital.hospital_name) + .filter(BizCollectionHospital.id.in_(hospital_ids)) + .all() + ): + hospital_name_map[hosp.id] = hosp.hospital_name or "" + + # 批量解析采集专员 / 销售人员名称 + sales_user_ids = list( + {collection_map.get(did, {}).get("salesUserId", "") for did in donation_ids} - {""} + ) collector_ids = list({r.collector_user_id for r in rows if r.collector_user_id}) - user_name_map = _user_nick_name_map(db, collector_ids) + user_name_map = _user_nick_name_map(db, list(set(collector_ids + sales_user_ids))) result = [] for r in rows: - d = _donation_to_dict(r) + d = _donation_to_dict(r, db, tenant_no) d["collectorUserName"] = user_name_map.get(r.collector_user_id or "", "") pc = party_map.get(r.id) if pc: d["motherName"] = pc.get("motherName", "") d["motherPhone"] = pc.get("motherPhone", "") + d["motherIdCard"] = pc.get("motherIdCard", "") + d["fatherName"] = pc.get("fatherName", "") + d["newbornSex"] = pc.get("newbornSex", "") + d["deliveryMode"] = pc.get("deliveryMode", "") + d["newbornWeightGram"] = pc.get("newbornWeightGram") else: d["motherName"] = "" d["motherPhone"] = "" + d["motherIdCard"] = "" + d["fatherName"] = "" + d["newbornSex"] = "" + d["deliveryMode"] = "" + d["newbornWeightGram"] = None + ci = collection_map.get(r.id) + if ci: + d["regionName"] = region_name_map.get(ci.get("regionId", ""), "") + d["hospitalName"] = hospital_name_map.get(ci.get("hospitalId", ""), "") + d["salesUserName"] = user_name_map.get(ci.get("salesUserId", ""), "") + d["sampleCollectTime"] = ci.get("sampleCollectTime", "") + else: + d["regionName"] = "" + d["hospitalName"] = "" + d["salesUserName"] = "" + d["sampleCollectTime"] = "" result.append(d) return { @@ -438,7 +551,7 @@ def get_donation_detail( if not main: return None - out: Dict[str, Any] = {"donation": _donation_to_dict(main)} + out: Dict[str, Any] = {"donation": _donation_to_dict(main, db, tenant_no)} # 采集专员名称 user_ids: List[str] = [] @@ -509,30 +622,16 @@ def create_donation( ) -> BizResearchDonation: """创建科研捐赠单(含 party / collection / samples + transports)。""" raw = _normalize_fields(data) + _resolve_donation_payload(raw) - # 验证知情同意书 consent = (raw.get("consent_form_urls") or "").strip() - if not consent: - raise ValueError("知情同意书不能为空") donation_no = (raw.get("donation_no") or "").strip() if not donation_no: - raise ValueError("捐赠编号不能为空") - - # 检查编号唯一 - dup = ( - db.query(BizResearchDonation) - .filter( - and_( - BizResearchDonation.donation_no == donation_no, - BizResearchDonation.tenant_no == tenant_no, - BizResearchDonation.del_flag == 0, - ) - ) - .first() + raise ValueError("捐赠协议号不能为空") + assert_biz_no_available( + db, tenant_no=tenant_no, biz_no=donation_no, for_donation=True ) - if dup: - raise ValueError("捐赠编号已存在") now = datetime.now() obj = BizResearchDonation( @@ -606,6 +705,7 @@ def update_donation( raise ValueError("捐赠单不存在或无权操作") raw = _normalize_fields(data) + _resolve_donation_payload(raw) now = datetime.now() # 主表字段 @@ -613,14 +713,28 @@ def update_donation( "donation_status", "donation_date", "source_type", "source_protocol_id", "consent_form_urls", "attachment_urls", "collector_user_id", "effective_desc", "expire_desc", - "void_desc", "to_storage_desc", "remark", + "void_desc", "to_storage_desc", "remark", "donation_no", ] _MAIN_DT_FIELDS = [ "effective_time", "expire_time", "void_time", "to_storage_time", ] for k in _MAIN_STR_FIELDS: if k in raw: - setattr(obj, k, (raw[k] or "").strip() or None) + if k == "donation_no": + pn = (raw[k] or "").strip() + if not pn: + raise ValueError("捐赠协议号不能为空") + if pn != (obj.donation_no or ""): + assert_biz_no_available( + db, + tenant_no=tenant_no, + biz_no=pn, + exclude_donation_id=donation_id, + for_donation=True, + ) + obj.donation_no = pn + else: + setattr(obj, k, (raw[k] or "").strip() or None) for k in _MAIN_DT_FIELDS: if k in raw: val = raw[k] @@ -955,6 +1069,14 @@ def convert_from_storage( ) if not proto: raise ValueError("源协议单不存在") + if proto.protocol_status != "sxz": + raise ValueError("仅生效中的协议可转科研") + if has_active_convert_link(db, tenant_no, protocol_id=protocol_id): + raise ValueError("该协议已存在转移关联,无法重复转科研") + + pn_src = (proto.protocol_no or "").strip() + if not pn_src: + raise ValueError("源协议单协议号为空,无法转科研") # 读取源子资源 src_party = ( @@ -1004,7 +1126,7 @@ def convert_from_storage( transport_by_sample = {t.sample_id: t for t in src_transports} now = datetime.now() - donation_no = f"KY{now.strftime('%Y%m%d%H%M%S')}" + generate_uuid()[:6] + donation_no = pn_src # 创建主记录 donation = BizResearchDonation( @@ -1014,7 +1136,7 @@ def convert_from_storage( donation_date=now.strftime("%Y%m%d"), source_type="from_storage", source_protocol_id=protocol_id, - consent_form_urls=proto.protocol_attachments, + attachment_urls=(proto.protocol_attachments or "").strip() or None, collector_user_id=proto.collector_user_id, tenant_no=tenant_no, order_num=0, @@ -1151,6 +1273,17 @@ def convert_from_storage( proto.update_time = now db.add(proto) + create_convert_link( + db, + convert_type="storage_to_research", + protocol_id=protocol_id, + research_donation_id=did, + biz_no=donation_no, + tenant_no=tenant_no, + user_id=user_id, + now=now, + ) + db.commit() db.refresh(donation) return donation @@ -1167,8 +1300,8 @@ def convert_to_storage( *, tenant_no: str, user_id: Optional[str] = None, -) -> BizCollectionProtocol: - """从科研捐赠单转换创建细胞存储协议单。""" +) -> BizResearchDonation: + """从科研捐赠单转换创建细胞存储协议单,返回更新后的捐赠单。""" # 读取源捐赠 donation = ( db.query(BizResearchDonation) @@ -1183,6 +1316,27 @@ def convert_to_storage( ) if not donation: raise ValueError("捐赠单不存在") + if donation.donation_status != "sxz": + raise ValueError("仅生效中的捐赠单可转细胞存储") + partner = get_convert_partner(db, tenant_no, donation_id=donation_id) + if partner: + linked_proto = ( + db.query(BizCollectionProtocol) + .filter( + and_( + BizCollectionProtocol.id == partner["protocol_id"], + BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0, + ) + ) + .first() + ) + if linked_proto and linked_proto.protocol_status != "zky": + raise ValueError("该捐赠单已转细胞存储,无法重复操作") + + dn_src = (donation.donation_no or "").strip() + if not dn_src: + raise ValueError("源捐赠单协议号为空,无法转细胞存储") src_party = get_party(db, donation_id, tenant_no) src_coll = get_collection(db, donation_id, tenant_no) @@ -1191,14 +1345,36 @@ def convert_to_storage( transport_by_sample = {t.sample_id: t for t in src_transports} now = datetime.now() - protocol_no = f"XC{now.strftime('%Y%m%d%H%M%S')}" + generate_uuid()[:6] + protocol_no = dn_src + protocol_no_id = None + if (donation.source_protocol_id or "").strip(): + src_proto = ( + db.query(BizCollectionProtocol) + .filter( + and_( + BizCollectionProtocol.id == donation.source_protocol_id.strip(), + BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0, + ) + ) + .first() + ) + if src_proto and src_proto.protocol_no_id: + protocol_no_id = src_proto.protocol_no_id + + protocol_attachment_urls = _merge_comma_separated_urls( + donation.consent_form_urls, + donation.attachment_urls, + ) # 创建协议单 proto = BizCollectionProtocol( id=generate_uuid(), + protocol_no_id=protocol_no_id, protocol_no=protocol_no, protocol_status="dsx", register_date=now.strftime("%Y%m%d"), + protocol_attachments=protocol_attachment_urls, collector_user_id=donation.collector_user_id, tenant_no=tenant_no, order_num=0, @@ -1335,9 +1511,19 @@ def convert_to_storage( donation.update_time = now db.add(donation) + upsert_convert_link_for_to_storage( + db, + protocol_id=pid, + research_donation_id=donation_id, + biz_no=protocol_no, + tenant_no=tenant_no, + user_id=user_id, + now=now, + ) + db.commit() - db.refresh(proto) - return proto + db.refresh(donation) + return donation # --------------------------------------------------------------------------- @@ -1453,6 +1639,12 @@ def _create_samples( samples: list, now: datetime, ) -> List[BizResearchDonationSample]: + validate_sample_codes_not_used( + db, + tenant_no=tenant_no, + samples=samples, + exclude_donation_id=donation_id, + ) result = [] for idx, raw_item in enumerate(samples or []): raw = _normalize_fields(raw_item) diff --git a/backend/app/crud/sop_document.py b/backend/app/crud/sop_document.py index f40d4428f..c8017dd94 100644 --- a/backend/app/crud/sop_document.py +++ b/backend/app/crud/sop_document.py @@ -9,16 +9,23 @@ from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid +from app.crud.collection_protocol import urls_to_attachment_list from app.models.models import BizSopDocument -def _row_to_dict(s: BizSopDocument) -> Dict[str, Any]: +def _row_to_dict( + s: BizSopDocument, + db: Optional[Session] = None, + tenant_no: Optional[str] = None, +) -> Dict[str, Any]: + tn = tenant_no or s.tenant_no return { "id": s.id, "sopCode": s.sop_code, "sopName": s.sop_name, "versionNo": s.version_no or "", "sopAttachments": s.sop_attachments or "", + "attachmentFiles": urls_to_attachment_list(s.sop_attachments, db, tn) if db else [], "tenantNo": s.tenant_no, "orderNum": s.order_num if s.order_num is not None else 0, "remark": s.remark or "", @@ -60,7 +67,7 @@ def list_sop_documents( .limit(page_size) .all() ) - return [_row_to_dict(x) for x in rows], int(total) + return [_row_to_dict(x, db, tenant_no) for x in rows], int(total) def get_sop_document(db: Session, sop_id: str) -> Optional[BizSopDocument]: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 457ccf971..bf5fec0bb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -30,6 +30,7 @@ from app.models.models import ( BizProcessReagent, BizProcessConsumable, BizProcessOutput, + BizProcessDataCollection, ) from app.models.research_donation import ( # noqa: F401 BizResearchDonation, @@ -38,6 +39,7 @@ from app.models.research_donation import ( # noqa: F401 BizResearchDonationCollection, BizResearchDonationSample, BizResearchDonationSampleTransport, + BizProtocolDonationConvert, ) User = SysUser @@ -77,4 +79,5 @@ __all__ = [ "BizProcessReagent", "BizProcessConsumable", "BizProcessOutput", + "BizProcessDataCollection", ] diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 8fec0a028..ee5df57ad 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1711,6 +1711,7 @@ class BizConsumable(Base): consumable_name = Column(String(100), nullable=False, comment="耗材名称") spec_model = Column(String(200), nullable=True, comment="规格型号") standard_usage = Column(String(100), nullable=True, comment="标准用量") + unit = Column(String(64), nullable=True, comment="单位") description = Column(String(500), nullable=True, comment="描述") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") @@ -1838,6 +1839,7 @@ class BizConsumablePackItem(Base): consumable_name = Column(String(100), nullable=True, comment="耗材名称") spec_model = Column(String(200), nullable=True, comment="耗材规格型号") standard_usage = Column(String(100), nullable=True, comment="标准用量") + unit = Column(String(64), nullable=True, comment="单位") remark = Column(String(512), nullable=True, comment="备注") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") @@ -1908,6 +1910,7 @@ class BizProcessConsumable(Base): consumable_name = Column(String(100), nullable=True, comment="耗材名称") spec_model = Column(String(200), nullable=True, comment="耗材规格型号") standard_usage = Column(String(100), nullable=True, comment="标准用量") + unit = Column(String(64), nullable=True, comment="单位") is_required = Column(CHAR(1), nullable=True, default="1", comment="是否必需(1-必须 0-可选)") remark = Column(String(512), nullable=True, comment="备注") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") @@ -1941,4 +1944,22 @@ class BizProcessOutput(Base): del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") +class BizProcessDataCollection(Base): + __tablename__ = "biz_process_data_collection" + + id = Column(String(64), primary_key=True, comment="主键ID") + process_id = Column(String(64), nullable=False, comment="工艺id") + collection_name = Column(String(200), nullable=False, comment="采集项名称") + unit = Column(String(64), nullable=True, comment="单位") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + from app.models.qc_param import BizQcItemParam, BizQcTaskResult # noqa: E402,F401 diff --git a/backend/app/models/research_donation.py b/backend/app/models/research_donation.py index e8be91bf9..284c4bf83 100644 --- a/backend/app/models/research_donation.py +++ b/backend/app/models/research_donation.py @@ -176,3 +176,24 @@ class BizResearchDonationSampleTransport(Base): update_by = Column(String(64), nullable=True, comment="更新人id") update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class BizProtocolDonationConvert(Base): + """细胞存储协议与科研捐赠转移关联 biz_protocol_donation_convert""" + + __tablename__ = "biz_protocol_donation_convert" + + id = Column(String(64), primary_key=True, comment="主键ID") + convert_type = Column(String(32), nullable=False, comment="storage_to_research/research_to_storage") + protocol_id = Column(String(64), nullable=False, comment="采集协议单ID") + research_donation_id = Column(String(64), nullable=False, comment="科研捐赠单ID") + biz_no = Column(String(64), nullable=False, comment="转移完成时共用编号快照") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") diff --git a/docs/sql/biz_protocol_donation_convert.sql b/docs/sql/biz_protocol_donation_convert.sql new file mode 100644 index 000000000..4fccbe851 --- /dev/null +++ b/docs/sql/biz_protocol_donation_convert.sql @@ -0,0 +1,21 @@ +-- 细胞存储采集协议 ⇄ 科研捐赠 转移关联(一对一) +CREATE TABLE IF NOT EXISTS `biz_protocol_donation_convert` ( + `id` varchar(64) NOT NULL COMMENT '主键ID', + `convert_type` varchar(32) NOT NULL COMMENT 'storage_to_research/research_to_storage', + `protocol_id` varchar(64) NOT NULL COMMENT '采集协议单ID', + `research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID', + `biz_no` varchar(64) NOT NULL COMMENT '转移完成时共用编号快照', + `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', + `order_num` int(11) DEFAULT 0 COMMENT '排序号', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id', + `update_time` datetime NOT NULL COMMENT '更新时间', + `del_flag` tinyint(4) NOT NULL DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (`id`), + KEY `idx_pdc_protocol` (`protocol_id`, `del_flag`), + KEY `idx_pdc_donation` (`research_donation_id`, `del_flag`), + KEY `idx_pdc_biz_no` (`tenant_no`, `biz_no`, `del_flag`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='细胞存储协议与科研捐赠转移关联'; diff --git a/docs/sql/cell_storage_menu_reorder.sql b/docs/sql/cell_storage_menu_reorder.sql new file mode 100644 index 000000000..c6c5a56e9 --- /dev/null +++ b/docs/sql/cell_storage_menu_reorder.sql @@ -0,0 +1,8 @@ +-- 细胞存储管理二级菜单:重命名 + 排序(已执行 zkxw.sql 的环境可单独运行) +-- 顺序:采集协议单录入、采集协议单管理、缴费信息、缴费确认(财务)、退费确认(财务) + +UPDATE `sys_menu` SET `menu_name` = '采集协议单录入', `order_num` = 1 WHERE `id` = '50000000000000000000000000000002'; +UPDATE `sys_menu` SET `menu_name` = '采集协议单管理', `order_num` = 2 WHERE `id` = '50000000000000000000000000000003'; +UPDATE `sys_menu` SET `order_num` = 3 WHERE `id` = '904b5173519411f1944300163e3bdd96'; +UPDATE `sys_menu` SET `order_num` = 4 WHERE `id` = '50000000000000000000000000000004'; +UPDATE `sys_menu` SET `order_num` = 5 WHERE `id` = '50000000000000000000000000000005'; diff --git a/docs/sql/production.sql b/docs/sql/production.sql index d613e007a..da9285bf0 100644 --- a/docs/sql/production.sql +++ b/docs/sql/production.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `biz_consumable` ( `consumable_name` varchar(100) NOT NULL COMMENT '耗材名称', `spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号', `standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量', + `unit` varchar(64) DEFAULT NULL COMMENT '单位', `description` varchar(500) DEFAULT NULL COMMENT '描述', `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', `order_num` int(11) DEFAULT NULL COMMENT '排序号', @@ -144,6 +145,7 @@ CREATE TABLE IF NOT EXISTS `biz_consumable_pack_item` ( `consumable_name` varchar(100) DEFAULT NULL COMMENT '耗材名称', `spec_model` varchar(200) DEFAULT NULL COMMENT '耗材规格型号', `standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量', + `unit` varchar(64) DEFAULT NULL COMMENT '单位', `remark` varchar(512) DEFAULT NULL COMMENT '备注', `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', `order_num` int(11) DEFAULT NULL COMMENT '排序号', @@ -219,6 +221,7 @@ CREATE TABLE IF NOT EXISTS `biz_process_consumable` ( `consumable_name` varchar(100) DEFAULT NULL COMMENT '耗材名称', `spec_model` varchar(200) DEFAULT NULL COMMENT '耗材规格型号', `standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量', + `unit` varchar(64) DEFAULT NULL COMMENT '单位', `is_required` char(1) DEFAULT '1' COMMENT '是否必需(1-必须 0-可选)', `remark` varchar(512) DEFAULT NULL COMMENT '备注', `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', @@ -269,13 +272,13 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('90000000000000000000000000000002', '耗材管理', '90000000000000000000000000000001', 'C', 'consumable', '/production/consumable/index', 'production:consumable:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); +-- 耗材包管理 +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES +('90000000000000000000000000000007', '耗材包管理', '90000000000000000000000000000001', 'C', 'consumable-pack', '/production/consumable-pack/index', 'production:consumable-pack:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); + -- 试剂管理 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES -('90000000000000000000000000000003', '试剂管理', '90000000000000000000000000000001', 'C', 'reagent', '/production/reagent/index', 'production:reagent:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); - --- SOP文档 -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES -('90000000000000000000000000000004', 'SOP文档', '90000000000000000000000000000001', 'C', 'sop', '/production/sop/index', 'production:sop:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); +('90000000000000000000000000000003', '试剂管理', '90000000000000000000000000000001', 'C', 'reagent', '/production/reagent/index', 'production:reagent:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); -- 工艺工序分类 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES @@ -285,10 +288,10 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('90000000000000000000000000000006', '工艺产出物类型', '90000000000000000000000000000001', 'C', 'output-type', '/production/output-type/index', 'production:output-type:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); --- 耗材包管理 -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES -('90000000000000000000000000000007', '耗材包管理', '90000000000000000000000000000001', 'C', 'consumable-pack', '/production/consumable-pack/index', 'production:consumable-pack:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); - -- 工艺库管理 INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES -('90000000000000000000000000000008', '工艺库管理', '90000000000000000000000000000001', 'C', 'process', '/production/process/index', 'production:process:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); +('90000000000000000000000000000008', '工艺库管理', '90000000000000000000000000000001', 'C', 'process', '/production/process/index', 'production:process:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); + +-- SOP文档 +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES +('90000000000000000000000000000004', 'SOP文档', '90000000000000000000000000000001', 'C', 'sop', '/production/sop/index', 'production:sop:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0); diff --git a/docs/sql/production_consumable_add_unit.sql b/docs/sql/production_consumable_add_unit.sql new file mode 100644 index 000000000..c61d879f1 --- /dev/null +++ b/docs/sql/production_consumable_add_unit.sql @@ -0,0 +1,9 @@ +-- 生产耗材相关表增加「单位」字段(与试剂 unit 一致,数据来自物品单位管理) +ALTER TABLE `biz_consumable` + ADD COLUMN `unit` varchar(64) DEFAULT NULL COMMENT '单位' AFTER `standard_usage`; + +ALTER TABLE `biz_consumable_pack_item` + ADD COLUMN `unit` varchar(64) DEFAULT NULL COMMENT '单位' AFTER `standard_usage`; + +ALTER TABLE `biz_process_consumable` + ADD COLUMN `unit` varchar(64) DEFAULT NULL COMMENT '单位' AFTER `standard_usage`; diff --git a/docs/sql/production_menu_reorder.sql b/docs/sql/production_menu_reorder.sql new file mode 100644 index 000000000..d1e1af8b4 --- /dev/null +++ b/docs/sql/production_menu_reorder.sql @@ -0,0 +1,10 @@ +-- 生产管理二级菜单排序调整(已执行 production.sql 的环境可单独运行) +-- 顺序:耗材管理、耗材包管理、试剂管理、工艺工序分类、工艺产出物类型、工艺库管理、SOP文档 + +UPDATE `sys_menu` SET `order_num` = 1 WHERE `id` = '90000000000000000000000000000002'; +UPDATE `sys_menu` SET `order_num` = 2 WHERE `id` = '90000000000000000000000000000007'; +UPDATE `sys_menu` SET `order_num` = 3 WHERE `id` = '90000000000000000000000000000003'; +UPDATE `sys_menu` SET `order_num` = 4 WHERE `id` = '90000000000000000000000000000005'; +UPDATE `sys_menu` SET `order_num` = 5 WHERE `id` = '90000000000000000000000000000006'; +UPDATE `sys_menu` SET `order_num` = 6 WHERE `id` = '90000000000000000000000000000008'; +UPDATE `sys_menu` SET `order_num` = 7 WHERE `id` = '90000000000000000000000000000004'; diff --git a/docs/sql/production_process_data_collection.sql b/docs/sql/production_process_data_collection.sql new file mode 100644 index 000000000..a49e9d0b0 --- /dev/null +++ b/docs/sql/production_process_data_collection.sql @@ -0,0 +1,23 @@ +-- --------------------------------------------------------------------------- +-- 工艺数据采集项 biz_process_data_collection +-- 用途:在工艺库中配置该工艺需要采集的数据信息(名称、单位) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `biz_process_data_collection` ( + `id` varchar(64) NOT NULL COMMENT '主键ID', + `process_id` varchar(64) NOT NULL COMMENT '工艺id', + `collection_name` varchar(200) NOT NULL COMMENT '采集项名称', + `unit` varchar(64) DEFAULT NULL COMMENT '单位', + `tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', + `order_num` int(11) DEFAULT NULL COMMENT '排序号', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除', + PRIMARY KEY (`id`), + KEY `idx_pdc_process` (`process_id`, `del_flag`), + KEY `idx_pdc_tenant` (`tenant_no`, `del_flag`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺数据采集项'; diff --git a/docs/sql/research_donation_menu_rename.sql b/docs/sql/research_donation_menu_rename.sql new file mode 100644 index 000000000..b1ae0e714 --- /dev/null +++ b/docs/sql/research_donation_menu_rename.sql @@ -0,0 +1,6 @@ +-- 科研捐赠管理二级菜单名称调整(已执行 research_donation_tables.sql 的环境可单独运行) +-- 捐赠协议单 → 捐赠协议单录入 +-- 捐赠协议审核 → 捐赠协议单管理 + +UPDATE `sys_menu` SET `menu_name` = '捐赠协议单录入', `update_time` = NOW() WHERE `id` = '60000000000000000000000000000002' AND `del_flag` = 0; +UPDATE `sys_menu` SET `menu_name` = '捐赠协议单管理', `update_time` = NOW() WHERE `id` = '60000000000000000000000000000003' AND `del_flag` = 0; diff --git a/docs/sql/research_donation_tables.sql b/docs/sql/research_donation_tables.sql index 902cd4fd6..874dcc75c 100644 --- a/docs/sql/research_donation_tables.sql +++ b/docs/sql/research_donation_tables.sql @@ -171,6 +171,6 @@ CREATE TABLE IF NOT EXISTS `biz_research_donation_sample_transport` ( INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000001', '科研捐赠管理', '2', 'M', '/research-donation', '/index/index', NULL, 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000002', '捐赠协议单', '60000000000000000000000000000001', 'C', 'list', '/research-donation/list/index', 'biz:researchDonation:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000002', '捐赠协议单录入', '60000000000000000000000000000001', 'C', 'list', '/research-donation/list/index', 'biz:researchDonation:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000003', '捐赠协议审核', '60000000000000000000000000000001', 'C', 'audit', '/research-donation/audit/index', 'biz:researchDonationAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000003', '捐赠协议单管理', '60000000000000000000000000000001', 'C', 'audit', '/research-donation/audit/index', 'biz:researchDonationAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0); diff --git a/docs/sql/seed_production_basic_data.sql b/docs/sql/seed_production_basic_data.sql new file mode 100644 index 000000000..cf7f64f8d --- /dev/null +++ b/docs/sql/seed_production_basic_data.sql @@ -0,0 +1,155 @@ +-- ============================================================ +-- 生产管理模块基础测试数据 +-- 涵盖:耗材 → 试剂 → 工序分类 → 产出类型 → 耗材包(含明细) +-- 租户编号:zkxw +-- 执行前若需重复导入,可先执行文末「清理脚本」 +-- ============================================================ + +-- ---------------------------- +-- 1. 生产耗材(8 条) +-- ---------------------------- +INSERT INTO `biz_consumable` + (`id`, `consumable_code`, `consumable_name`, `spec_model`, `standard_usage`, `unit`, `description`, + `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + ('prod_cons_001', 'HC001', 'T25 细胞培养瓶', '25cm²', '1', '个', '贴壁细胞常规培养', + 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_002', 'HC002', '15mL 离心管', '无菌', '2', '支', '细胞传代离心分装', + 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_003', 'HC003', '10mL 移液管', '无菌独立包装', '1', '支', '培养基与试剂移液', + 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_004', 'HC004', '0.22μm 针头滤器', '33mm', '1', '个', '培养基除菌过滤', + 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_005', 'HC005', '细胞刮刀', '18cm', '1', '把', '贴壁细胞消化前刮取', + 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_006', 'HC006', '冻存管', '2mL 外旋盖', '6', '支', '细胞冻存分装', + 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_007', 'HC007', '细胞计数板', '血球计数板', '1', '块', '台盼蓝计数用', + 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cons_008', 'HC008', '一次性无菌手套', 'M 码', '1', '双', '操作防护', + 'zkxw', 8, NULL, NULL, '', NOW(), NULL, NOW(), 0); + + +-- ---------------------------- +-- 2. 试剂(6 条) +-- ---------------------------- +INSERT INTO `biz_reagent` + (`id`, `reagent_code`, `reagent_name`, `spec`, `standard_usage`, `unit`, `description`, + `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + ('prod_reag_001', 'SJ001', 'DMEM 高糖培养基', '500mL', '10', 'mL', '基础培养基', + 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_reag_002', 'SJ002', '胎牛血清 FBS', '500mL', '1', 'mL', '培养基添加剂', + 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_reag_003', 'SJ003', '0.25% 胰酶-EDTA', '100mL', '2', 'mL', '细胞消化', + 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_reag_004', 'SJ004', 'PBS 缓冲液', '500mL', '5', 'mL', '细胞洗涤', + 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_reag_005', 'SJ005', '台盼蓝染液', '10mL', '0.1', 'mL', '死活细胞染色计数', + 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_reag_006', 'SJ006', 'DMSO 细胞冻存液', '100mL', '0.5', 'mL', '冻存保护剂', + 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0); + + +-- ---------------------------- +-- 3. 工艺工序分类(5 条) +-- ---------------------------- +INSERT INTO `biz_process_category` + (`id`, `category_code`, `category_name`, + `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + ('prod_pcat_001', 'GXFL001', '细胞复苏', 'zkxw', 1, '从液氮取出至接种前', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pcat_002', 'GXFL002', '细胞传代', 'zkxw', 2, '贴壁细胞常规扩增', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pcat_003', 'GXFL003', '细胞冻存', 'zkxw', 3, '液氮冻存入库', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pcat_004', 'GXFL004', '收获与制剂', 'zkxw', 4, '上清收获或细胞收集', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pcat_005', 'GXFL005', '质检取样', 'zkxw', 5, '留样与送检取样', NULL, '', NOW(), NULL, NOW(), 0); + + +-- ---------------------------- +-- 4. 工艺产出物类型(5 条) +-- ---------------------------- +INSERT INTO `biz_output_type` + (`id`, `type_code`, `type_name`, + `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + ('prod_otype_001', 'CCLX001', '活细胞', 'zkxw', 1, '传代或扩增后的细胞悬液', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_otype_002', 'CCLX002', '冻存细胞', 'zkxw', 2, '液氮冻存管', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_otype_003', 'CCLX003', '上清液', 'zkxw', 3, '培养上清产物', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_otype_004', 'CCLX004', '留样', 'zkxw', 4, '质检留样管', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_otype_005', 'CCLX005', '废料', 'zkxw', 5, '废弃培养物', NULL, '', NOW(), NULL, NOW(), 0); + + +-- ---------------------------- +-- 5. 耗材包(4 条:2 标准 + 2 自定义) +-- ---------------------------- +INSERT INTO `biz_consumable_pack` + (`id`, `pack_code`, `pack_name`, `usage_scene`, `pack_type`, `status`, + `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + ('prod_pack_001', 'HCB001', '细胞传代标准包', '贴壁细胞 T25 传代', 'bzhcb', '1', + 'zkxw', 1, '含培养瓶、离心管、移液管等', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pack_002', 'HCB002', '细胞冻存标准包', '液氮冻存分装', 'bzhcb', '1', + 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pack_003', 'HCB003', '复苏接种自定义包', '复苏后首次接种', 'zdyhcb', '1', + 'zkxw', 3, '按项目定制', NULL, '', NOW(), NULL, NOW(), 0), + ('prod_pack_004', 'HCB004', '质检取样包(停用示例)', '留样分装', 'zdyhcb', '0', + 'zkxw', 4, '测试停用状态', NULL, '', NOW(), NULL, NOW(), 0); + + +-- ---------------------------- +-- 6. 耗材包明细(关联上述耗材包与耗材编码) +-- ---------------------------- +INSERT INTO `biz_consumable_pack_item` + (`id`, `pack_id`, `consumable_code`, `consumable_name`, `spec_model`, `standard_usage`, `unit`, `remark`, + `tenant_no`, `order_num`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) +VALUES + -- 细胞传代标准包 HCB001 + ('prod_cpi_001', 'prod_pack_001', 'HC001', 'T25 细胞培养瓶', '25cm²', '1', '个', NULL, + 'zkxw', 1, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_002', 'prod_pack_001', 'HC002', '15mL 离心管', '无菌', '2', '支', NULL, + 'zkxw', 2, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_003', 'prod_pack_001', 'HC003', '10mL 移液管', '无菌独立包装', '1', '支', NULL, + 'zkxw', 3, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_004', 'prod_pack_001', 'HC005', '细胞刮刀', '18cm', '1', '把', NULL, + 'zkxw', 4, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_005', 'prod_pack_001', 'HC008', '一次性无菌手套', 'M 码', '1', '双', NULL, + 'zkxw', 5, NULL, '', NOW(), NULL, NOW(), 0), + + -- 细胞冻存标准包 HCB002 + ('prod_cpi_006', 'prod_pack_002', 'HC002', '15mL 离心管', '无菌', '2', '支', NULL, + 'zkxw', 1, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_007', 'prod_pack_002', 'HC006', '冻存管', '2mL 外旋盖', '6', '支', NULL, + 'zkxw', 2, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_008', 'prod_pack_002', 'HC008', '一次性无菌手套', 'M 码', '1', '双', NULL, + 'zkxw', 3, NULL, '', NOW(), NULL, NOW(), 0), + + -- 复苏接种自定义包 HCB003 + ('prod_cpi_009', 'prod_pack_003', 'HC001', 'T25 细胞培养瓶', '25cm²', '2', '个', '双瓶接种', + 'zkxw', 1, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_010', 'prod_pack_003', 'HC004', '0.22μm 针头滤器', '33mm', '1', '个', NULL, + 'zkxw', 2, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_011', 'prod_pack_003', 'HC007', '细胞计数板', '血球计数板', '1', '块', NULL, + 'zkxw', 3, NULL, '', NOW(), NULL, NOW(), 0), + + -- 质检取样包 HCB004(停用包,明细仍保留供查看) + ('prod_cpi_012', 'prod_pack_004', 'HC002', '15mL 离心管', '无菌', '3', '支', NULL, + 'zkxw', 1, NULL, '', NOW(), NULL, NOW(), 0), + ('prod_cpi_013', 'prod_pack_004', 'HC006', '冻存管', '2mL 外旋盖', '2', '支', '留样管', + 'zkxw', 2, NULL, '', NOW(), NULL, NOW(), 0); + + +-- ============================================================ +-- 清理脚本(重复导入前可选执行,按 id 前缀删除) +-- ============================================================ +-- DELETE FROM `biz_consumable_pack_item` WHERE `id` LIKE 'prod_cpi_%'; +-- DELETE FROM `biz_consumable_pack` WHERE `id` LIKE 'prod_pack_%'; +-- DELETE FROM `biz_output_type` WHERE `id` LIKE 'prod_otype_%'; +-- DELETE FROM `biz_process_category` WHERE `id` LIKE 'prod_pcat_%'; +-- DELETE FROM `biz_reagent` WHERE `id` LIKE 'prod_reag_%'; +-- DELETE FROM `biz_consumable` WHERE `id` LIKE 'prod_cons_%'; diff --git a/docs/sql/zkxw.sql b/docs/sql/zkxw.sql index e5d92a5a2..fa3a6d3e9 100644 --- a/docs/sql/zkxw.sql +++ b/docs/sql/zkxw.sql @@ -4872,10 +4872,10 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('30000000000000000000000000000001', '工作流管理', '2', 'M', '/workflow', '/index/index', NULL, 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('4760DB762DA842DABA41C0687D6F418F', '2', 'A68CEECA9A7543B29DF9FCF6BE0801A2', 'M', '12', NULL, NULL, 1, '1', '1', 'zkxw', 0, '12', 'd3000000000000000000000000000001', '', '2026-05-14 15:27:55', 'd3000000000000000000000000000001', '2026-05-14 15:27:59', 1); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000001', '细胞存储管理', '2', 'M', '/cell-storage', '/index/index', NULL, 1, '1', '1', 'zkxw', 4, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000002', '采集协议单', '50000000000000000000000000000001', 'C', 'protocol', '/cell-storage/protocol/index', 'biz:cellStorageProtocol:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000003', '采集协议单审核', '50000000000000000000000000000001', 'C', 'protocol-audit', '/cell-storage/protocol-audit/index', 'biz:cellStorageProtocolAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000004', '缴费确认(财务)', '50000000000000000000000000000001', 'C', 'payment-confirm', '/cell-storage/payment-confirm/index', 'biz:cellStoragePaymentConfirm:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-17 11:23:39', 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000005', '退费确认(财务)', '50000000000000000000000000000001', 'C', 'refund-confirm', '/cell-storage/refund-confirm/index', 'biz:cellStorageRefundConfirm:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-17 11:23:39', 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000002', '采集协议单录入', '50000000000000000000000000000001', 'C', 'protocol', '/cell-storage/protocol/index', 'biz:cellStorageProtocol:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000003', '采集协议单管理', '50000000000000000000000000000001', 'C', 'protocol-audit', '/cell-storage/protocol-audit/index', 'biz:cellStorageProtocolAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-14 00:00:00', 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000004', '缴费确认(财务)', '50000000000000000000000000000001', 'C', 'payment-confirm', '/cell-storage/payment-confirm/index', 'biz:cellStoragePaymentConfirm:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-17 11:23:39', 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000005', '退费确认(财务)', '50000000000000000000000000000001', 'C', 'refund-confirm', '/cell-storage/refund-confirm/index', 'biz:cellStorageRefundConfirm:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-17 11:23:39', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('50000000000000000000000000000006', '分期缴费预警', '50000000000000000000000000000001', 'C', 'installment-warning', '/cell-storage/installment-warning/index', 'biz:cellStorageInstallmentWarning:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-14 00:00:00', NULL, '2026-05-17 12:07:03', 1); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('70000000000000000000000000000001', '质量检测', '2', 'M', '/quality-inspection', '/index/index', NULL, 1, '1', '1', 'zkxw', 4, NULL, NULL, '', '2026-05-17 16:22:32', NULL, '2026-05-17 16:22:32', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('70000000000000000000000000000002', '样本接收', '70000000000000000000000000000001', 'C', 'sample-reception', '/quality-inspection/sample-reception/index', 'biz:sampleReception:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', '2026-05-17 16:22:32', NULL, '2026-05-17 16:22:32', 0); @@ -4884,7 +4884,7 @@ INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `co INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('70000000000000000000000000000006', '实验室自检项目', '70000000000000000000000000000001', 'C', 'lab-test-item', '/quality-inspection/lab-test-item/index', 'biz:qcLabTestItem:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-17 19:37:18', NULL, '2026-05-17 19:37:18', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('70000000000000000000000000000007', '质检工单', '70000000000000000000000000000001', 'C', 'qc-work-order', '/quality-inspection/qc-work-order/index', 'biz:qcWorkOrder:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', '2026-05-17 19:37:18', NULL, '2026-05-17 19:37:18', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('70000000000000000000000000000008', '质检任务', '70000000000000000000000000000001', 'C', 'qc-task', '/quality-inspection/qc-task/index', 'biz:qcTask:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-17 19:37:18', NULL, '2026-05-17 19:37:18', 0); -INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('904b5173519411f1944300163e3bdd96', '缴费信息', '50000000000000000000000000000001', 'C', 'payment-info', '/cell-storage/payment-info/index', 'cellStorage:paymentInfo:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-17 10:03:14', NULL, '2026-05-17 10:03:14', 0); +INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('904b5173519411f1944300163e3bdd96', '缴费信息', '50000000000000000000000000000001', 'C', 'payment-info', '/cell-storage/payment-info/index', 'cellStorage:paymentInfo:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', '2026-05-17 10:03:14', NULL, '2026-05-17 10:03:14', 0); INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('A68CEECA9A7543B29DF9FCF6BE0801A2', '1', '2', 'M', '1', NULL, NULL, 1, '1', '1', 'zkxw', 0, '1', 'd3000000000000000000000000000001', '', '2026-05-14 15:27:46', 'd3000000000000000000000000000001', '2026-05-14 15:28:01', 1); COMMIT; diff --git a/docs/superpowers/specs/2026-05-19-process-route-management-design.md b/docs/superpowers/specs/2026-05-19-process-route-management-design.md new file mode 100644 index 000000000..39114d08e --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-process-route-management-design.md @@ -0,0 +1,322 @@ +# 工艺路线管理模块设计规格 + +## 概述 + +实现需求文档 2.3.8 节「工艺路线配置」。在生产管理菜单下的「工艺库管理」之后新增「工艺路线管理」菜单。 + +核心功能:维护工艺路线基础信息(路线编码、名称、产品类型、版本号),支持同编码多版本管理(轻量版:is_current 标记当前版本),并通过拖拽式三栏弹窗配置路线中的工艺步骤和子表数据。 + +## 设计决策 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 版本号 | 同编码多版本 + is_current 标记当前版本 | 轻量版:同 route_code 可有多条记录,仅一个可标记为当前版本。无发布/草稿状态机 | +| 子表范围 | 完整复刻4张子表(试剂/耗材/产出物/数据采集) | 从工艺库拖入时克隆默认数据,用户可覆盖编辑 | +| 排列模式 | 线性排列(A→B→C→D) | 卡片从上到下,无需分支逻辑 | +| 可编辑范围 | 全部字段可编辑(基础信息+4张子表) | 右侧面板展示完整工艺信息供修改 | +| 保存方式 | 实时保存,拖入即创建后端记录 | 每步操作即时持久化,弹窗关闭无需额外保存 | +| UI方案 | 方案A:单弹窗 + vue-draggable-plus | 项目中已有3处成熟用法,跨栏拖拽通过HTML5 Drag API组合实现 | +| 步骤删除 | 支持删除,软删除步骤+级联软删除子表 | 中栏卡片上有删除按钮,确认后删除 | + +## 依赖 + +| 依赖模块 | 用途 | 状态 | +|----------|------|------| +| 细胞产品类型管理(biz_cell_product_type) | 产品类型下拉 | 已实现 | +| 工艺工序分类(biz_process_category) | 工艺分组展示 | 已实现 | +| 工艺库(biz_process + 4张子表) | 拖入时的数据源和克隆来源 | 已实现 | +| SOP文档(biz_sop_document) | 步骤关联SOP | 已实现 | +| vue-draggable-plus@0.6.0 | 卡片排序拖拽 | 已安装 | +| sortablejs@1.15.7 | vue-draggable-plus 底层引擎 | 已安装 | + +## SQL 表(6 张新建) + +所有表遵循项目公共字段规范:`id` VARCHAR(64) UUID → 业务字段 → `tenant_no` → `order_num` → `remark` → `create_by` → `create_dept_id` → `create_time` → `update_by` → `update_time` → `del_flag`。 + +### 1. biz_process_route — 工艺路线主表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| route_code | VARCHAR(64) NOT NULL | 路线编码 | +| route_name | VARCHAR(100) NOT NULL | 路线名称 | +| product_type_id | VARCHAR(64) DEFAULT NULL | 细胞产品类型ID(引用 biz_cell_product_type) | +| version | VARCHAR(50) DEFAULT NULL | 版本号(用户手填文本,如 V1.0、V2.0) | +| is_current | CHAR(1) DEFAULT '0' | 是否当前版本(0-否 1-是)。同 route_code 下仅一条记录 is_current=1 | +| + 公共字段 | | | + +索引:`idx_route_tenant(tenant_no, del_flag)` + +### 2. biz_process_route_step — 路线工序步骤 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| route_id | VARCHAR(64) NOT NULL | 所属路线ID | +| process_id | VARCHAR(64) DEFAULT NULL | 来源工艺库工艺ID(工艺被删时可null) | +| step_order | INT DEFAULT NULL | 步骤顺序(拖拽排序时自动计算) | +| process_code | VARCHAR(64) DEFAULT NULL | 工序代码(从工艺库克隆,可编辑) | +| category_id | VARCHAR(64) DEFAULT NULL | 工序分类ID(可编辑) | +| process_name | VARCHAR(100) DEFAULT NULL | 工序名称(可编辑) | +| description | VARCHAR(500) DEFAULT NULL | 描述(可编辑) | +| equipment_name | VARCHAR(200) DEFAULT NULL | 预计使用设备(可编辑) | +| estimated_duration | VARCHAR(100) DEFAULT NULL | 预估耗时(可编辑) | +| sop_ids | VARCHAR(1000) DEFAULT NULL | 关联SOP文档ID(可编辑,逗号分隔) | +| + 公共字段 | | | + +索引:`idx_prs_route(route_id, del_flag)`、`idx_prs_tenant(tenant_no, del_flag)` + +### 3. biz_process_route_step_reagent — 步骤试剂 + +字段完全镜像 `biz_process_reagent`,但外键改为 `step_id`(关联 biz_process_route_step.id): + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| step_id | VARCHAR(64) NOT NULL | 所属步骤ID | +| reagent_code | VARCHAR(64) DEFAULT NULL | 试剂编码 | +| reagent_name | VARCHAR(100) DEFAULT NULL | 试剂名称 | +| spec | VARCHAR(200) DEFAULT NULL | 规格 | +| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 | +| unit | VARCHAR(64) DEFAULT NULL | 单位 | +| is_key_material | CHAR(1) DEFAULT '0' | 是否关键物料(0-否 1-是) | +| + 公共字段 | | | + +索引:`idx_prsr_step(step_id, del_flag)` + +### 4. biz_process_route_step_consumable — 步骤耗材 + +字段完全镜像 `biz_process_consumable`,外键改为 `step_id`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| step_id | VARCHAR(64) NOT NULL | 所属步骤ID | +| consumable_code | VARCHAR(64) DEFAULT NULL | 耗材编码 | +| consumable_name | VARCHAR(100) DEFAULT NULL | 耗材名称 | +| spec_model | VARCHAR(200) DEFAULT NULL | 规格型号 | +| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 | +| unit | VARCHAR(64) DEFAULT NULL | 单位 | +| is_required | CHAR(1) DEFAULT '1' | 是否必需(1-必须 0-可选) | +| remark | VARCHAR(512) DEFAULT NULL | 备注 | +| + 公共字段(不含 remark) | | | + +索引:`idx_prsc_step(step_id, del_flag)` + +### 5. biz_process_route_step_output — 步骤产出物 + +字段完全镜像 `biz_process_output`,外键改为 `step_id`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| step_id | VARCHAR(64) NOT NULL | 所属步骤ID | +| output_type_id | VARCHAR(64) DEFAULT NULL | 产出类型ID | +| output_name | VARCHAR(200) DEFAULT NULL | 产出物名称 | +| output_barcode | VARCHAR(200) DEFAULT NULL | 产出物条码 | +| + 公共字段 | | | + +索引:`idx_prso_step(step_id, del_flag)` + +### 6. biz_process_route_step_data_collection — 步骤数据采集 + +字段完全镜像 `biz_process_data_collection`,外键改为 `step_id`: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(64) PK | UUID | +| step_id | VARCHAR(64) NOT NULL | 所属步骤ID | +| collection_name | VARCHAR(100) DEFAULT NULL | 采集信息名称 | +| unit | VARCHAR(64) DEFAULT NULL | 单位 | +| + 公共字段 | | | + +索引:`idx_prsdc_step(step_id, del_flag)` + +### 菜单数据 + +在「工艺库管理」(order_num=6)之后、「SOP文档」(order_num=7)之前插入,SOP 文档 order_num 改为 8: + +```sql +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag) +VALUES ('90000000000000000000000000000010', '工艺路线管理', '90000000000000000000000000000001', 'C', 'process-route', '/production/process-route/index', 'production:process-route:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-19 00:00:00', NULL, '2026-05-19 00:00:00', 0); + +-- SOP文档 order_num 从 7 改为 8 +UPDATE sys_menu SET order_num = 8 WHERE id = '90000000000000000000000000000004'; +``` + +## 后端 API + +路由前缀:`/api/admin/production/route/` + +### 路线主表 CRUD + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/list` | 路线分页列表。支持筛选:route_name(模糊)、route_code(精确)、product_type_id。排序:order_num ASC, create_time DESC。带 tenant_no 隔离。 | +| POST | `/create` | 创建路线主表。必填:route_code, route_name。自动填充 tenant_no。默认 is_current=0。 | +| PUT | `/update` | 更新路线主表信息(编码、名称、产品类型、版本号、备注)。 | +| PUT | `/set-current` | 设置指定路线为当前版本。逻辑:先将同 route_code + tenant_no 下所有记录 is_current 置 0,再将目标记录 is_current 置 1。 | +| DELETE | `/delete/{id}` | 软删除路线(del_flag=1)+ 级联软删除所有步骤及步骤子表。 | +| GET | `/detail/{id}` | 获取路线详情:主表 + 步骤列表(按 step_order ASC)+ 每个步骤的4张子表数据。 | + +### 步骤操作(实时保存) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/step/add` | 从工艺库拖入创建步骤。入参:route_id, process_id, step_order。后端读取 biz_process 基础信息克隆到 step 表,读取4张子表数据批量克隆到 route_step_* 子表。返回完整 step 对象(含子表)。 | +| PUT | `/step/update` | 更新步骤基础信息(右侧面板字段修改时触发)。 | +| PUT | `/step/reorder` | 批量重排步骤顺序。入参:route_id, step_ids[](新顺序数组)。遍历更新 step_order。 | +| DELETE | `/step/delete/{id}` | 软删除步骤(del_flag=1)+ 级联软删除该步骤的4张子表数据。 | +| PUT | `/step/reagent/update` | 更新步骤试剂子表。入参:step_id, items[](完整替换)。 | +| PUT | `/step/consumable/update` | 更新步骤耗材子表。入参:step_id, items[](完整替换)。 | +| PUT | `/step/output/update` | 更新步骤产出子表。入参:step_id, items[](完整替换)。 | +| PUT | `/step/data-collection/update` | 更新步骤数据采集子表。入参:step_id, items[](完整替换)。 | + +### 关键后端逻辑 + +**拖入克隆(step/add)**: +1. 校验 route_id 存在且属于当前租户 +2. 从 `biz_process` 读取工艺基础信息 +3. 插入 `biz_process_route_step`(克隆字段:process_code, category_id, process_name, description, equipment_name, estimated_duration, sop_ids) +4. 从 `biz_process_reagent` 等4张子表批量读取并插入对应 `biz_process_route_step_*` +5. 返回完整 step 对象 + +**级联软删除**: +- 删除路线 → 遍历所有步骤设 del_flag=1 → 遍历每步子表设 del_flag=1 +- 删除步骤 → 遍历该步骤子表设 del_flag=1 + +**子表更新(完整替换策略)**: +- 接收 items[] 后,将现有记录 del_flag=1,然后批量插入新记录 +- 避免逐条 diff,简化逻辑 + +## 前端设计 + +### 文件清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `web-admin/src/views/production/process-route/index.vue` | 新建 | 路线列表页 + 路线图配置弹窗 | +| `web-admin/src/api/production.ts` | 追加 | route 相关 API 函数 | +| `web-admin/src/router/modules/sqxp.ts` | 追加 | process-route 路由条目 | + +### 列表页 + +遵循标准列表模板(参考 `src/views/system/unit/index.vue`): + +- **搜索栏**:路线名称(模糊)、路线编码(精确)、产品类型(下拉,数据来自细胞产品类型管理列表 API) +- **工具栏**:新增按钮(单一 primary) +- **表格列**:路线名称、路线编码、产品类型名称、版本号、当前版本(is_current=1 时显示绿色 `el-tag` 标签)、备注、创建时间 +- **操作列**(fixed="right", min-width ≥ 260px):路线图配置、编辑、设为当前版本(仅 is_current=0 时显示)、删除 +- **新增/编辑弹窗**:标准表单(label-position="top"),字段:路线编码、路线名称、产品类型(下拉)、版本号(文本)、备注 + +### 路线图配置弹窗 + +**弹窗属性**:`width="90vw"`,`destroy-on-close`,标题显示路线名称和版本号 + +**三栏布局**(flex): + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 路线图配置 - {路线名称} v{版本号} [关闭] │ +├────────────┬──────────────────────┬──────────────────────────┤ +│ 工艺库 │ 工艺流程布局 │ 工艺详情 │ +│ (240px) │ (flex:1) │ (360px) │ +│ │ │ │ +│ [分类折叠] │ ┌────────────┐ │ 工序名称: [___] │ +│ > 细胞培养 │ │ 步骤1: xxx │ ←拖 │ 工序分类: [下拉] │ +│ [卡片] │ └────────────┘ │ 描述: [___] │ +│ [卡片] │ ┌────────────┐ │ 设备: [___] │ +│ > 病毒转染 │ │ 步骤2: xxx │ │ 预估耗时: [___] │ +│ [卡片] │ └────────────┘ │ SOP: [选择] │ +│ > ... │ │ ────────────── │ +│ │ [+ 拖入工艺卡片] │ [试剂][耗材][产出][采集] │ +│ │ │ (子表tab切换编辑) │ +└────────────┴──────────────────────┴──────────────────────────┘ +``` + +**左栏 — 工艺库面板(240px 固定宽度)**: +- 使用 `el-collapse` 按工序分类(biz_process_category)分组展示 +- 每组内展示工艺小卡片:工序名称 + 编码 +- 卡片使用 HTML5 `draggable="true"` + `@dragstart` 传递 `process_id` +- 无分类的工艺归入"未分类"组 +- 仅展示当前租户未删除的工艺 + +**中栏 — 流程布局区(flex:1 自适应)**: +- 使用 `vue-draggable-plus`(VueDraggable 组件)包裹步骤卡片列表 +- 每张步骤卡片显示:步骤序号(圆角标签)+ 工艺名称 + 操作按钮(删除图标) +- 支持内部排序拖拽(handle 拖拽手柄,使用 ``) +- 空白区域通过 `@dragover.prevent` + `@drop` 接收从左栏拖入的工艺卡片 +- 拖入时调用 `POST /step/add`,即时创建后端记录 +- 排序变化时调用 `PUT /step/reorder` +- 点击卡片 → 选中态(高亮边框)→ 触发右栏加载详情 +- 卡片上的删除按钮 → 确认弹窗 → `DELETE /step/delete/{id}` → 从列表移除 + +**右栏 — 工艺详情编辑面板(360px 固定宽度)**: +- **顶部**:工艺基础信息表单 + - 工序名称(el-input) + - 工序分类(el-select,下拉数据来自工艺工序分类) + - 描述(el-input textarea) + - 设备名称(el-input) + - 预估耗时(el-input) + - SOP文档(el-select multiple,数据来自 SOP 文档列表) + - 字段修改时 `@change` 触发 `PUT /step/update` 实时保存 +- **底部**:4 个 el-tab-pane 切换子表编辑 + - **试剂 tab**:表格列(试剂编码、名称、规格、标准用量、单位、是否关键物料),底部"添加"按钮新增行,每行有删除按钮。修改后实时保存 `PUT /step/reagent/update` + - **耗材 tab**:表格列(耗材编码、名称、规格型号、标准用量、单位、是否必需、备注),同上交互 + - **产出 tab**:表格列(产出类型[下拉选择产出物类型]、名称、条码),同上交互 + - **数据采集 tab**:表格列(采集名称、单位),同上交互 +- 未选中任何步骤时显示空状态提示:"请从左侧工艺库拖入工艺,或点击已添加的步骤查看详情" + +### 数据流 + +``` +拖入流程: + 左栏卡片 dragstart(process_id) + → 中栏 drop 事件 + → POST /step/add {route_id, process_id, step_order: list.length + 1} + → 后端克隆工艺+子表 → 返回完整 step + → 前端追加到步骤列表 + +排序流程: + 中栏内 vue-draggable-plus 拖拽排序 + → @end 回调 + → PUT /step/reorder {route_id, step_ids: [...]} + +编辑流程: + 右栏字段 @change + → PUT /step/update {step_id, ...fields} + + 右栏子表增删改 + → PUT /step/{sub-table}/update {step_id, items: [...]} + +删除步骤: + 卡片删除按钮 → ElMessageBox.confirm + → DELETE /step/delete/{step_id} + → 前端从列表移除 + +点击步骤: + 中栏卡片 click + → 设置 selectedStepId + → 右栏加载该步骤详情(从列表数据中取,无需额外 API) +``` + +## 实现文件清单 + +| 层 | 文件 | 操作 | +|----|------|------| +| SQL | `docs/sql/production_process_route.sql` | 新建:6张表DDL + 菜单INSERT + SOP order_num UPDATE | +| Model | `backend/app/models/models.py` | 追加 6 个 SQLAlchemy Model 类 | +| Model | `backend/app/models/__init__.py` | 追加 6 个 export | +| CRUD | `backend/app/crud/process_route.py` | 新建:路线CRUD + 步骤CRUD + 子表CRUD + 克隆逻辑 | +| API | `backend/app/api/production.py` | 追加 route 子路由(挂载到 production router 下) | +| Schema | `backend/app/schemas/process_route.py` | 新建:Pydantic v2 schemas | +| 前端 API | `web-admin/src/api/production.ts` | 追加 route 相关函数(~15个 API 函数) | +| 前端页面 | `web-admin/src/views/production/process-route/index.vue` | 新建:列表页 + 三栏拖拽配置弹窗 | +| 路由 | `web-admin/src/router/modules/sqxp.ts` | 追加 process-route 路由条目 | + +## 风险与注意事项 + +1. **跨容器拖拽**:左栏(HTML5 Drag)→ 中栏(vue-draggable-plus)的跨容器拖入是核心难点。需在 drop 事件中正确获取 process_id 并调用 API。参考项目中 FlowDesigner.vue 的 HTML5 drag 实现。 +2. **大弹窗性能**:90vw 弹窗内三栏 + 多个子表可能数据量大。步骤列表应限制每页展示(暂无分页需求,路线步骤通常 < 50)。 +3. **工艺库删除影响**:步骤的 process_id 引用工艺库,但工艺被删除后步骤保留(process_id 可 null)。右栏编辑时不依赖 process_id 回查,所有数据已克隆到步骤中。 +4. **子表更新策略**:使用完整替换(del旧 + insert新),避免复杂 diff。注意 UUID 生成和 tenant_no 继承。 diff --git a/docs/superpowers/specs/2026-05-19-protocol-donation-convert-design.md b/docs/superpowers/specs/2026-05-19-protocol-donation-convert-design.md new file mode 100644 index 000000000..8f54bc562 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-protocol-donation-convert-design.md @@ -0,0 +1,339 @@ +# 细胞存储 ⇄ 科研捐赠 转移改造方案 + +> 日期:2026-05-19 +> 状态:已实施(2026-05-19) +> 关联模块:采集协议单管理、捐赠协议单管理 + +--- + +## 1. 背景与问题 + +### 1.1 业务场景 + +- **协议转科研**:细胞存储采集协议(`sxz`)→ 新建科研捐赠单,源协议状态 `zky`。 +- **转细胞存储**:科研捐赠单(`sxz`)→ 新建采集协议单,源捐赠状态 `zckc`。 + +产品约定: + +1. 转移时 **协议号与样本编码与源单一致**(同一业务在不同模块各一张单)。 +2. 转移后用户 **可能单独调整** 采集协议号或捐赠协议号(两边不必长期同号)。 +3. 二次编辑保存时不应误报「协议号已存在」「样本编码已被占用」。 + +### 1.2 现状问题 + +| 问题 | 原因 | +|------|------| +| 转移后协议号为 `KY…` / `XC…` 临时串 | `convert_from_storage` / `convert_to_storage` 临时生成编号 | +| 二次保存报样本编码已占用 | 全局唯一校验未识别「转移配对单」 | +| 同号需求与跨表唯一校验冲突 | `protocol_no` 与 `donation_no` 互斥,未对配对单豁免 | + +--- + +## 2. 改造目标 + +| # | 目标 | +|---|------| +| G1 | 转移瞬间:新单 `protocol_no` / `donation_no` = 源单展示号;样本 `sample_code` / `sample_code_id` 原样拷贝 | +| G2 | 建立 **转移关联** 持久化记录(中间表),供保存/改号校验查询 | +| G3 | 样本编码、协议号唯一性:对 **配对单据 ID** 豁免,不依赖编号字符串是否相同 | +| G4 | 转移后允许用户 **独立修改** 本单协议号/捐赠号,**不自动同步** 对端 | +| G5 | 非配对单据仍严格执行全局唯一,不影响手工新建/审核等流程 | + +--- + +## 3. 方案概述 + +```mermaid +flowchart TB + subgraph transfer [转移事务] + A[源单状态变更 zky/zckc] + B[新建目标单 同号同码] + C[写入关联表 biz_protocol_donation_convert] + end + subgraph save [编辑保存] + D[根据当前单 ID 查配对 protocol_id / donation_id] + E[协议号校验 排除配对单] + F[样本编码校验 排除配对单样本行] + end + transfer --> save +``` + +**核心原则**:配对关系由 **关联表 + 双方主键 ID** 锁定;`biz_no` 仅记录转移完成时的初始编号(审计),**不参与** 豁免判断。 + +--- + +## 4. 数据库设计 + +### 4.1 新建表 `biz_protocol_donation_convert` + +脚本路径:`docs/sql/biz_protocol_donation_convert.sql` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | VARCHAR(64) | 主键 UUID | +| `convert_type` | VARCHAR(32) | `storage_to_research` / `research_to_storage` | +| `protocol_id` | VARCHAR(64) | `biz_collection_protocol.id` | +| `research_donation_id` | VARCHAR(64) | `biz_research_donation.id` | +| `biz_no` | VARCHAR(64) | **转移完成时** 的共用编号快照(不随改号自动更新) | +| `tenant_no` | VARCHAR(64) | 租户 | +| 公共字段 | — | `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` | + +**索引与约束(建议)** + +- `UNIQUE (protocol_id)`:同一采集协议仅允许一条有效转移关联(应用层保证 `del_flag=0` 唯一)。 +- `UNIQUE (research_donation_id)`:同一科研捐赠仅允许一条有效转移关联。 +- `KEY (tenant_no, biz_no, del_flag)`:按初始编号排查。 + +**不新建** 主表字段为一期必选项;科研侧已有 `source_type`、`source_protocol_id` 可继续用于列表筛选与展示。 + +### 4.2 与现有表关系 + +``` +biz_collection_protocol (1) ──< biz_protocol_donation_convert >── (1) biz_research_donation + │ │ + ├── biz_protocol_sample ├── biz_research_donation_sample +``` + +一对一:一次转移产生一对主单 + 一条关联记录。 + +--- + +## 5. 转移业务规则 + +### 5.1 协议转科研(`convert_from_storage`) + +**前置** + +- 源协议存在、`tenant_no` 匹配、`del_flag=0`。 +- 源协议状态 = `sxz`(生效中)。 +- 该 `protocol_id` 尚未存在有效关联记录。 + +**事务内操作** + +| 步骤 | 操作 | +|------|------| +| 1 | 新建 `biz_research_donation`:`donation_no = proto.protocol_no`,`donation_status=dsx`,`source_type=from_storage`,`source_protocol_id=protocol_id` | +| 2 | 复制 party / collection / samples(+transport),样本编码与源一致 | +| 3 | 源协议 `protocol_status=zky`,写 `to_research_time` / `to_research_desc` | +| 4 | 插入关联表:`convert_type=storage_to_research`,`biz_no=proto.protocol_no` | + +**删除**:`KY` + 时间戳 + UUID 临时号逻辑。 + +### 5.2 转细胞存储(`convert_to_storage`) + +**前置** + +- 源捐赠存在、`sxz`、无有效关联。 + +**事务内操作** + +| 步骤 | 操作 | +|------|------| +| 1 | 新建 `biz_collection_protocol`:`protocol_no = donation.donation_no`,`protocol_status=dsx`;`protocol_no_id` 可按 `biz_no` 反查号池(可选) | +| 2 | 复制子表,样本同码;价格字段置空 | +| 3 | 源捐赠 `donation_status=zckc`,写 `to_storage_time` / `to_storage_desc` | +| 4 | 插入关联表:`convert_type=research_to_storage`,`biz_no=donation.donation_no` | + +**删除**:`XC` + 时间戳临时号逻辑。 + +### 5.3 转移后改号(用户可能调整) + +| 规则 | 说明 | +|------|------| +| 允许改号 | 采集协议可改 `protocol_no`;科研捐赠可改 `donation_no`(需确认前端是否已暴露字段,后端须支持校验) | +| 不同步对端 | 改 A 单号 **不自动修改** B 单号(方案 A) | +| 关联表 `biz_no` | **保留转移快照**,不随改号更新 | +| 可选 UI 提示 | 改号成功后提示「配对单据当前编号为 xxx」(二期) | + +--- + +## 6. 校验规则改造 + +### 6.1 公共能力(新建) + +位置建议:`backend/app/crud/protocol_donation_convert.py`(或合入 `research_donation.py`) + +| 函数 | 说明 | +|------|------| +| `create_convert_link(db, ...)` | 转移事务内写入关联 | +| `get_convert_partner(db, tenant_no, *, protocol_id=None, donation_id=None)` | 返回 `{ protocol_id, research_donation_id, biz_no, convert_type }` 或 `None` | +| `get_partner_sample_codes(db, tenant_no, protocol_id, donation_id)` | 配对单上所有 `del_flag=0` 的 `sample_code` 集合 | + +### 6.2 样本编码唯一性 + +改造:`collection_protocol.validate_sample_codes_not_used` + +```text +占用集合 = 全租户 采集样本表 ∪ 科研捐赠样本表(del_flag=0) +占用集合 -= 配对 protocol_id 下样本编码 +占用集合 -= 配对 research_donation_id 下样本编码 +若 待保存编码 ∩ 占用集合 非空 → 报错 +``` + +**调用点** + +- `collection_protocol.save_samples`:传入当前 `collection_protocol_id`,查 partner 后排除。 +- `research_donation._create_samples`:传入当前 `research_donation_id`,查 partner 后排除。 + +> 本单 upsert 会先软删旧样本行,排除逻辑必须基于 **配对单 ID**,与双方编号是否一致无关。 + +### 6.3 协议号 / 捐赠号跨表唯一性 + +改造:`create_collection_protocol`、`update_collection_protocol`、`create_donation`、`update_donation`(若支持改 `donation_no`)。 + +**判断「编号是否冲突」时:** + +```text +冲突对象 = 其他采集协议.protocol_no(排除当前 protocol_id) + ∪ 其他科研捐赠.donation_no(排除当前 donation_id) + +若存在 convert 配对: + - 更新采集协议时:再排除 配对 research_donation_id 对应行的 donation_no + - 更新科研捐赠时:再排除 配对 protocol_id 对应行的 protocol_no + +仅当 新编号 落在「冲突对象」中时报错 +``` + +**说明**:配对单占用 **整行单据** 豁免,不要求新号与对端当前号相同。例如协议改为 A001、捐赠仍为 A002,只要无第三方占用,均应通过。 + +### 6.4 非转移场景 + +- 手工新建、无关联记录:保持现有严格唯一。 +- 批量转移失败策略:维持现有「逐条调用、部分成功」行为。 + +--- + +## 7. 后端改动清单 + +| 序号 | 文件 | 改动要点 | +|------|------|----------| +| 1 | `docs/sql/biz_protocol_donation_convert.sql` | 建表 | +| 2 | `backend/app/models/research_donation.py` | `BizProtocolDonationConvert` | +| 3 | `backend/app/models/__init__.py` | 导出 Model | +| 4 | `backend/app/crud/protocol_donation_convert.py` | 关联 CRUD + 查询(新建) | +| 5 | `backend/app/crud/research_donation.py` | `convert_*` 同号同码 + 写关联;`_create_samples` 传排除参数;`update_donation` 支持 `donation_no` 改号校验 | +| 6 | `backend/app/crud/collection_protocol.py` | `validate_sample_codes_not_used`、`save_samples`、create/update 协议号校验 | +| 7 | `backend/app/api/research_donation.py` | 无路由变更(可选详情返回 `convertPartner`) | + +**API**:不新增对外接口;现有: + +- `POST /web/admin/research-donations/convert-from-storage` +- `POST /web/admin/research-donations/{id}/convert-to-storage` + +--- + +## 8. 前端改动清单 + +### 8.1 一期(随然后端联调) + +| 项 | 改动 | +|----|------| +| 转移确认文案 | 说明「将创建新单,**初始**协议号与样本编码与源单一致」 | +| 编辑表单 | 转移生成的新单:**协议号/捐赠号保持可编辑**(勿做只读) | +| 保存逻辑 | 无改动,依赖后端豁免 | + +### 8.2 二期(体验增强) + +| 项 | 改动 | +|----|------| +| 查看弹窗 | 展示「转移关联」:对端单据类型、编号、跳转 | +| 改号提示 | 保存后若与对端编号不一致,轻提示对端当前号 | + +--- + +## 9. 历史数据 + +| 类型 | 处理 | +|------|------| +| 已用 `KY…`/`XC…` 生成的转移单 | 脚本按 `source_protocol_id`、`to_research_desc`、`remark` 尝试配对并补关联表;编号是否改为源单同号需 **业务确认后** 再批量更新 | +| 无法配对 | 保持现状,仅新转移走新逻辑 | + +脚本建议:`backend/scripts/backfill_protocol_donation_convert.py`(可选)。 + +--- + +## 10. 实施步骤 + +| 阶段 | 内容 | 产出 | +|------|------|------| +| P1 | SQL + Model + 关联 CRUD | 表可建、可查询配对 | +| P2 | 改造 `convert_from_storage` / `convert_to_storage` | 转移同号同码 + 写关联 | +| P3 | 样本编码校验 + `save_samples` / `_create_samples` | 二次编辑样本保存通过 | +| P4 | 协议号/捐赠号 create & update 豁免 | 改号不误报、可独立改号 | +| P5 | 联调 + 回归 + 确认文案 | 测试清单通过 | +| P6 | 历史数据脚本(可选) | 补关联记录 | + +--- + +## 11. 测试用例 + +### 11.1 转移 + +| # | 步骤 | 预期 | +|---|------|------| +| T1 | `sxz` 协议转科研 | 捐赠号=协议号;样本码一致;关联表 1 条;源单 `zky` | +| T2 | `sxz` 捐赠转存储 | 协议号=捐赠号;样本码一致;关联表 1 条;源单 `zckc` | +| T3 | 非 `sxz` 转移 | 400 业务错误 | +| T4 | 已转移协议再次转科研 | 失败 | + +### 11.2 二次保存(样本) + +| # | 步骤 | 预期 | +|---|------|------| +| T5 | 转移后不改编码,直接保存 | 成功 | +| T6 | 修改非编码字段后保存 | 成功 | + +### 11.3 改号 + +| # | 步骤 | 预期 | +|---|------|------| +| T7 | 只改采集协议号 | 成功;捐赠号可不变 | +| T8 | 只改捐赠号 | 成功 | +| T9 | 改为第三方已占用号 | 失败 | +| T10 | 改为配对单当前持有的号 | 成功(豁免) | + +### 11.4 回归 + +| # | 步骤 | 预期 | +|---|------|------| +| T11 | 无关联手工新建协议+样本 | 重复编码仍失败 | +| T12 | 审核、状态变更、删除 | 行为不变 | + +--- + +## 12. 风险与待定项 + +| 项 | 说明 | 建议 | +|----|------|------| +| 号池下发状态 | 转移同号后 `biz_collection_protocol_no` 是否标记已下发 | 二期与基础数据模块对齐 | +| `protocol_no_id` | 转存储时是否回填号池 ID | 可选:按 `biz_no` 查池 | +| 链式转移 | 同一业务多次互转 | 当前按 **一对一** 设计;若需支持需单独立项 | +| 软删一方 | 另一方编辑是否仍豁免 | 建议:**仍豁免**(关联保留,占用仅计 `del_flag=0`) | + +--- + +## 13. 已确认产品决策 + +| 决策 | 结论 | +|------|------| +| 转移初始编号 | 与源单相同 | +| 转移初始样本编码 | 与源单相同 | +| 转移后改号 | 允许,两边可不一致 | +| 改号是否同步对端 | **否** | +| 关联表 `biz_no` | 转移快照,不随改号更新 | +| 豁免依据 | 关联表 **单据 ID**,非编号字符串 | + +--- + +## 14. 参考代码位置(改造前) + +| 逻辑 | 文件 | +|------|------| +| 转移(临时号) | `backend/app/crud/research_donation.py` → `convert_from_storage` / `convert_to_storage` | +| 样本编码校验 | `backend/app/crud/collection_protocol.py` → `validate_sample_codes_not_used` | +| 协议号互斥 | `create_collection_protocol` / `update_collection_protocol` / `create_donation` | +| 前端转移调用 | `web-admin/src/api/research-donation.ts` | + +--- + +*文档结束* diff --git a/opencode.json b/opencode.json new file mode 100644 index 000000000..39e17fe0c --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "https://gh-proxy.org/https://raw.githubusercontent.com/conventional-commits/conventionalcommits.org/refs/heads/master/content/v1.0.0/index.zh-hans.md", + "https://gh-proxy.org/https://raw.githubusercontent.com/vttmlin/opencode-extensions/refs/heads/master/rules/common/sdd-requirement-triage.md", + "https://gh-proxy.org/https://raw.githubusercontent.com/vttmlin/opencode-extensions/refs/heads/master/rules/common/interaction-convention.md", + "https://gh-proxy.org/https://raw.githubusercontent.com/vttmlin/opencode-extensions/refs/heads/master/rules/common/task-execution-strategy.md", + "https://gh-proxy.org/https://raw.githubusercontent.com/vttmlin/opencode-extensions/refs/heads/master/rules/common/network-proxy-config.md" + ] +} diff --git a/web-admin/src/api/business.ts b/web-admin/src/api/business.ts index 9f20b2a6b..c1ef441f1 100644 --- a/web-admin/src/api/business.ts +++ b/web-admin/src/api/business.ts @@ -132,6 +132,13 @@ export function fetchUploadAttachment(data: FormData) { }) } +export function fetchAttachmentDownloadUrl(data: { fileUrl: string; fileName?: string }) { + return request.post<{ downloadUrl: string }>({ + url: '/web/admin/attachment/download-url', + data + }) +} + export function fetchUploadProductImage(data: FormData) { return request.post({ url: '/web/admin/products/upload-image', diff --git a/web-admin/src/api/qc-task.ts b/web-admin/src/api/qc-task.ts index ba730e349..e08e158db 100644 --- a/web-admin/src/api/qc-task.ts +++ b/web-admin/src/api/qc-task.ts @@ -21,6 +21,7 @@ export type QcTask = { feeAmount?: number taskStatus: string thirdPartyAttachments?: string + attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }> completedTime?: string completedBy?: string remark?: string diff --git a/web-admin/src/api/research-donation.ts b/web-admin/src/api/research-donation.ts index 657b70f87..9d7cc15dd 100644 --- a/web-admin/src/api/research-donation.ts +++ b/web-admin/src/api/research-donation.ts @@ -100,7 +100,7 @@ export function fetchSubmitDonationAudit( // 状态变更 export function fetchDonationStatusChange( donationId: string, - data: { donationStatus: string; desc?: string; time?: string } + data: { newStatus: string; desc?: string; timeVal?: string } ) { return request.post({ url: `/web/admin/research-donations/${donationId}/status-change`, diff --git a/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue b/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue index e9776f05d..25e0a5e11 100644 --- a/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue +++ b/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue @@ -87,6 +87,7 @@ VideoPlay } from '@element-plus/icons-vue' import { fetchUploadAttachment } from '@/api/business' + import { downloadAttachmentFile } from '@/utils/download-attachment' import type { FinanceAttachmentItem } from './finance-attachments' const props = withDefaults( @@ -135,13 +136,7 @@ } function downloadFile(file: FinanceAttachmentItem) { - if (!file.fileUrl) return - const a = document.createElement('a') - a.href = fullFileUrl(file.fileUrl) - a.download = file.fileName || 'download' - a.target = '_blank' - a.rel = 'noopener' - a.click() + downloadAttachmentFile(file, { fullFileUrl }) } function removeAttachment(index: number) { diff --git a/web-admin/src/components/business/system-view-dialog/index.vue b/web-admin/src/components/business/system-view-dialog/index.vue index 0de14ac18..289e60050 100644 --- a/web-admin/src/components/business/system-view-dialog/index.vue +++ b/web-admin/src/components/business/system-view-dialog/index.vue @@ -37,6 +37,7 @@ +