提交
This commit is contained in:
1 parent
f269a994f4
commit
93cd506a90
71 files changed
+8297
-2207
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
38796
|
||||
11478
|
||||
@@ -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)}")
|
||||
+17
-74
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
"""获取存储实例"""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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="删除标记")
|
||||
@@ -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='细胞存储协议与科研捐赠转移关联';
|
||||
@@ -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';
|
||||
+13
-10
@@ -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);
|
||||
@@ -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`;
|
||||
@@ -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';
|
||||
@@ -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='工艺数据采集项';
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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_%';
|
||||
+5
-5
@@ -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;
|
||||
|
||||
|
||||
@@ -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 拖拽手柄,使用 `<el-icon><Rank /></el-icon>`)
|
||||
- 空白区域通过 `@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 继承。
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
*文档结束*
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<slot name="append" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -142,13 +142,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
path: 'exhibition-appointment',
|
||||
component: '/operations/exhibition-appointment/index',
|
||||
meta: { title: '展馆预约', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000005',
|
||||
name: 'OperationsDemo',
|
||||
path: 'demo',
|
||||
component: '/operations/demo/index',
|
||||
meta: { title: 'demo', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -218,7 +211,14 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
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',
|
||||
@@ -227,13 +227,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
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',
|
||||
@@ -267,14 +260,14 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
name: 'ResearchDonationList',
|
||||
path: 'list',
|
||||
component: '/research-donation/list/index',
|
||||
meta: { title: '捐赠协议单', keepAlive: true }
|
||||
meta: { title: '捐赠协议单录入', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000003',
|
||||
name: 'ResearchDonationAudit',
|
||||
path: 'audit',
|
||||
component: '/research-donation/audit/index',
|
||||
meta: { title: '捐赠协议审核', keepAlive: true }
|
||||
meta: { title: '捐赠协议单管理', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -422,6 +415,13 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
component: '/production/consumable/index',
|
||||
meta: { title: '耗材管理', keepAlive: true, menuCode: 'production:consumable:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000060',
|
||||
name: 'ProductionConsumablePack',
|
||||
path: 'consumable-pack',
|
||||
component: '/production/consumable-pack/index',
|
||||
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000020',
|
||||
name: 'ProductionReagent',
|
||||
@@ -429,13 +429,6 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
component: '/production/reagent/index',
|
||||
meta: { title: '试剂管理', keepAlive: true, menuCode: 'production:reagent:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000030',
|
||||
name: 'ProductionSop',
|
||||
path: 'sop',
|
||||
component: '/production/sop/index',
|
||||
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000040',
|
||||
name: 'ProductionProcessCategory',
|
||||
@@ -450,19 +443,19 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
component: '/production/output-type/index',
|
||||
meta: { title: '工艺产出物类型', keepAlive: true, menuCode: 'production:output-type:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000060',
|
||||
name: 'ProductionConsumablePack',
|
||||
path: 'consumable-pack',
|
||||
component: '/production/consumable-pack/index',
|
||||
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000070',
|
||||
name: 'ProductionProcess',
|
||||
path: 'process',
|
||||
component: '/production/process/index',
|
||||
meta: { title: '工艺库管理', keepAlive: true, menuCode: 'production:process:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000030',
|
||||
name: 'ProductionSop',
|
||||
path: 'sop',
|
||||
component: '/production/sop/index',
|
||||
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Vendored
+2
-2
@@ -542,7 +542,7 @@ declare namespace Api {
|
||||
id: string
|
||||
protocolNo: string
|
||||
issued: number
|
||||
/** 是否已在采集协议单主表占用(与号池「是否下发」无关) */
|
||||
/** 是否已在采集协议单或科研捐赠单中占用(与号池「是否下发」无关) */
|
||||
usedInProtocol?: boolean
|
||||
issuedUserId: string
|
||||
issuedUserName: string
|
||||
@@ -561,7 +561,7 @@ declare namespace Api {
|
||||
sampleCode: string
|
||||
sampleCodeType: string
|
||||
issued: number
|
||||
/** 是否已在协议单样本表中占用(与号池「是否下发」无关) */
|
||||
/** 是否已在采集协议单或科研捐赠单样本表中占用(与号池「是否下发」无关) */
|
||||
usedInProtocol?: boolean
|
||||
issuedUserId: string
|
||||
issuedUserName: string
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchAttachmentDownloadUrl } from '@/api/business'
|
||||
|
||||
export type AttachmentDownloadItem = {
|
||||
fileUrl?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
function triggerAnchorDownload(href: string, fileName: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
a.download = fileName
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
/** 附件下载:优先走后端预签名 URL,保证跨域场景下保留原始文件名与后缀。 */
|
||||
export async function downloadAttachmentFile(
|
||||
file: AttachmentDownloadItem,
|
||||
options?: { fullFileUrl?: (url: string) => string }
|
||||
) {
|
||||
const fileUrl = String(file.fileUrl || '').trim()
|
||||
if (!fileUrl) return
|
||||
const fileName = String(file.fileName || '').trim() || fileUrl.split('/').pop() || 'download'
|
||||
|
||||
try {
|
||||
const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
|
||||
if (res?.downloadUrl) {
|
||||
triggerAnchorDownload(res.downloadUrl, fileName)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 预签名失败时回退直连
|
||||
}
|
||||
|
||||
const resolveUrl = options?.fullFileUrl || ((url: string) => url)
|
||||
const href = resolveUrl(fileUrl)
|
||||
if (!href) {
|
||||
ElMessage.error('无法下载附件')
|
||||
return
|
||||
}
|
||||
triggerAnchorDownload(href, fileName)
|
||||
}
|
||||
@@ -22,5 +22,8 @@ export const fullscreenLoadingText = {
|
||||
save: '正在保存,请稍候…',
|
||||
delete: '正在删除,请稍候…',
|
||||
batchDelete: '正在批量删除,请稍候…',
|
||||
batchAudit: '正在提交审核,请稍候…'
|
||||
batchAudit: '正在提交审核,请稍候…',
|
||||
activate: '正在生效,请稍候…',
|
||||
voidAgreement: '正在作废,请稍候…',
|
||||
addItems: '正在添加,请稍候…'
|
||||
} as const
|
||||
@@ -242,6 +242,7 @@
|
||||
type FinanceAttachmentItem
|
||||
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
|
||||
import { maskIdCard } from '@/utils/mask-sensitive'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
formatPaymentAmount,
|
||||
formatPaymentDueDate,
|
||||
@@ -304,14 +305,7 @@
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(file: Record<string, unknown>) {
|
||||
const url = String(file.fileUrl || '')
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = fullFileUrl(url)
|
||||
a.download = String(file.fileName || 'download')
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
a.click()
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- 采集协议单审核 -->
|
||||
<!-- 采集协议单管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>采集协议单审核</h1>
|
||||
<p class="page-desc">查看与管理采集协议单审核数据。</p>
|
||||
<h1>采集协议单管理</h1>
|
||||
<p class="page-desc">查看与管理采集协议单及审核数据。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
|
||||
@@ -738,7 +738,7 @@
|
||||
|
||||
<!-- Tab 5: 缴费信息 -->
|
||||
<div id="form-section-payment" class="form-tab-pane">
|
||||
<div class="section-card section-card--gray">
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<div class="section-title-left"><h3>缴费信息</h3></div>
|
||||
</div>
|
||||
@@ -1194,6 +1194,7 @@
|
||||
type CollectionProtocolRow
|
||||
} from '@/api/collection-protocol'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
fetchCustomerList,
|
||||
fetchUserList,
|
||||
@@ -2056,7 +2057,7 @@
|
||||
if (!isEdit.value && formData.protocolNoId) {
|
||||
const picked = protocolNoOptions.value.find((x) => x.id === formData.protocolNoId)
|
||||
if (picked?.usedInProtocol) {
|
||||
ElMessage.warning(`协议号「${formData.protocolNo}」已存在协议单,请更换其他协议号`)
|
||||
ElMessage.warning(`协议号「${formData.protocolNo}」已被占用,请更换其他协议号`)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2066,7 +2067,7 @@
|
||||
const sc = sampleCodeOptions.value.find((x) => x.id === sampleRow.sampleCodeId)
|
||||
if (sc?.usedInProtocol) {
|
||||
ElMessage.warning(
|
||||
`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被其他协议单使用,请更换`
|
||||
`样本编码「${sampleRow.sampleCode || sc.sampleCode}」已被占用,请更换`
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2660,7 +2661,7 @@
|
||||
|
||||
/* ====== 协议号下拉检索 ====== */
|
||||
function protocolNoOptionLabel(p: Api.BizCollectionProtocolNo.ProtocolNoItem) {
|
||||
if (p.usedInProtocol) return `${p.protocolNo}(已建单)`
|
||||
if (p.usedInProtocol) return `${p.protocolNo}(已占用)`
|
||||
if (p.issued) return `${p.protocolNo}(已下发)`
|
||||
return p.protocolNo
|
||||
}
|
||||
@@ -2706,7 +2707,7 @@
|
||||
|
||||
/* ====== 样本编码选择 ====== */
|
||||
function sampleCodeOptionLabel(sc: Api.BizSampleCode.SampleCodeItem) {
|
||||
if (sc.usedInProtocol) return `${sc.sampleCode}(已建单)`
|
||||
if (sc.usedInProtocol) return `${sc.sampleCode}(已占用)`
|
||||
if (sc.issued) return `${sc.sampleCode}(已下发)`
|
||||
return sc.sampleCode
|
||||
}
|
||||
@@ -2894,15 +2895,7 @@
|
||||
}
|
||||
|
||||
function handleDownloadAttachment(file: any) {
|
||||
const url = fullFileUrl(file.fileUrl)
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.fileName || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
/* ====== 弹窗打开时自动初始化 ====== */
|
||||
@@ -2998,10 +2991,6 @@
|
||||
&--green {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
if (props.actionType === 'toResearch') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定将该协议转为科研捐赠吗?转科研后将创建新的科研捐赠记录。',
|
||||
'确定将该协议转为科研捐赠吗?将创建新的科研捐赠记录,初始协议号与样本编码与源单一致。',
|
||||
'转科研确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 缴费信息 -->
|
||||
<div id="view-section-payment" class="section-card section-card--gray">
|
||||
<div id="view-section-payment" class="section-card">
|
||||
<div class="section-title"><h3>缴费信息</h3></div>
|
||||
<!-- 缴费主表指标卡 -->
|
||||
<div class="payment-summary">
|
||||
@@ -664,6 +664,7 @@
|
||||
fetchHospitalList,
|
||||
fetchUserList
|
||||
} from '@/api/admin'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -1343,15 +1344,7 @@
|
||||
}
|
||||
|
||||
function downloadFile(file: Record<string, any>) {
|
||||
const url = fullFileUrl(file.fileUrl)
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.fileName || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function loadLookups(data: Record<string, any>) {
|
||||
@@ -1606,10 +1599,6 @@
|
||||
&--green {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- 采集协议单 -->
|
||||
<!-- 采集协议单录入 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>采集协议单</h1>
|
||||
<p class="page-desc">查看与管理采集协议单主数据。</p>
|
||||
<h1>采集协议单录入</h1>
|
||||
<p class="page-desc">录入与管理采集协议单主数据。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
type FinanceAttachmentItem
|
||||
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
|
||||
import { maskIdCard } from '@/utils/mask-sensitive'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
formatPaymentAmount,
|
||||
formatPaymentDueDate,
|
||||
@@ -321,14 +322,7 @@
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(file: Record<string, unknown>) {
|
||||
const url = String(file.fileUrl || '')
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = fullFileUrl(url)
|
||||
a.download = String(file.fileName || 'download')
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
a.click()
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
|
||||
@@ -425,10 +425,16 @@
|
||||
|
||||
// --- Delete ---
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductPrice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCellProductPrice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
// --- Batch delete ---
|
||||
|
||||
@@ -293,10 +293,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.CellProductSpec.CellProductSpecItem) {
|
||||
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductSpec(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCellProductSpec(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
|
||||
@@ -293,10 +293,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCellProductType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
|
||||
</div>
|
||||
|
||||
<div id="view-section-agreement" class="section-card section-card--gray">
|
||||
<div id="view-section-agreement" class="section-card">
|
||||
<div class="section-title"><h3>协议信息</h3></div>
|
||||
<div v-if="agreementList.length" class="agreement-cards">
|
||||
<div
|
||||
@@ -235,6 +235,7 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
fetchChannelInvoices,
|
||||
fetchChannelMailAddresses,
|
||||
@@ -301,7 +302,12 @@
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
const protocolAttachmentFiles = computed(() => {
|
||||
const urls = String(channel.value?.protocolAttachments || '').trim()
|
||||
const row = channel.value
|
||||
if (!row) return []
|
||||
if (Array.isArray(row.attachmentFiles) && row.attachmentFiles.length) {
|
||||
return row.attachmentFiles as Array<Record<string, any>>
|
||||
}
|
||||
const urls = String(row.protocolAttachments || '').trim()
|
||||
if (!urls) return []
|
||||
return urls
|
||||
.split(',')
|
||||
@@ -414,15 +420,7 @@
|
||||
}
|
||||
|
||||
function downloadFile(file: Record<string, any>) {
|
||||
const url = fullFileUrl(file.fileUrl)
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.fileName || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function loadSubTables() {
|
||||
@@ -586,10 +584,6 @@
|
||||
&--green {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
@@ -314,13 +314,28 @@
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="invoiceList.length" class="sub-cards">
|
||||
<div
|
||||
v-if="invoiceList.length"
|
||||
class="sub-cards"
|
||||
v-loading="!!deletingInvoiceId"
|
||||
element-loading-text="删除中…"
|
||||
>
|
||||
<div v-for="(row, $index) in invoiceList" :key="row.id || $index" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<h4>{{ row.companyName || '未命名' }}</h4>
|
||||
<div class="sub-card-actions">
|
||||
<el-button class="btn-op-item primary" link size="small" @click="handleEditInvoice(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" type="danger" link size="small" @click="handleDeleteInvoice(row)">删除</el-button>
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
:loading="deletingInvoiceId === row.id"
|
||||
:disabled="!!deletingInvoiceId"
|
||||
@click="handleDeleteInvoice(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-card-fields">
|
||||
@@ -366,13 +381,28 @@
|
||||
新增
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="mailList.length" class="sub-cards">
|
||||
<div
|
||||
v-if="mailList.length"
|
||||
class="sub-cards"
|
||||
v-loading="!!deletingMailId"
|
||||
element-loading-text="删除中…"
|
||||
>
|
||||
<div v-for="(row, $index) in mailList" :key="row.id || $index" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<h4>{{ row.mailAddress || '未命名' }}</h4>
|
||||
<div class="sub-card-actions">
|
||||
<el-button class="btn-op-item primary" link size="small" @click="handleEditMail(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" type="danger" link size="small" @click="handleDeleteMail(row)">删除</el-button>
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
type="danger"
|
||||
link
|
||||
size="small"
|
||||
:loading="deletingMailId === row.id"
|
||||
:disabled="!!deletingMailId"
|
||||
@click="handleDeleteMail(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-card-fields">
|
||||
@@ -524,7 +554,12 @@
|
||||
新增协议
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="agreementList.length" class="protocol-cards">
|
||||
<div
|
||||
v-if="agreementList.length"
|
||||
class="protocol-cards"
|
||||
v-loading="!!agreementOperatingId"
|
||||
element-loading-text="处理中…"
|
||||
>
|
||||
<div
|
||||
v-for="(agr, $index) in agreementList"
|
||||
:key="agr.id || $index"
|
||||
@@ -550,26 +585,36 @@
|
||||
<el-button
|
||||
v-if="agr.status === 'dsx'"
|
||||
class="btn-op-item primary"
|
||||
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'activate'"
|
||||
:disabled="!!agreementOperatingId"
|
||||
@click="handleActivateAgreement(agr)"
|
||||
>
|
||||
<el-icon><Check /></el-icon>生效
|
||||
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'activate')"><Check /></el-icon>生效
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="agr.status === 'dsx' || agr.status === 'sxz'"
|
||||
class="btn-op-item"
|
||||
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'void'"
|
||||
:disabled="!!agreementOperatingId"
|
||||
@click="handleVoidAgreement(agr)"
|
||||
>
|
||||
<el-icon><CircleClose /></el-icon>作废
|
||||
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'void')"><CircleClose /></el-icon>作废
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
:class="{ primary: agr.status !== 'dsx' }"
|
||||
:disabled="!!agreementOperatingId"
|
||||
@click="handleEditAgreement(agr)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteAgreement(agr)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
:loading="agreementOperatingId === agr.id && agreementOperatingAction === 'delete'"
|
||||
:disabled="!!agreementOperatingId"
|
||||
@click="handleDeleteAgreement(agr)"
|
||||
>
|
||||
<el-icon v-if="!(agreementOperatingId === agr.id && agreementOperatingAction === 'delete')"><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -632,6 +677,8 @@
|
||||
class="list-body list-wrap protocol-price-list-wrap"
|
||||
>
|
||||
<el-table
|
||||
v-loading="isAgreementPriceTableLoading(agr.id)"
|
||||
element-loading-text="删除中…"
|
||||
:data="agreementPricesMap[agr.id] || []"
|
||||
class="list-table protocol-card-price-table"
|
||||
stripe
|
||||
@@ -727,8 +774,14 @@
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row: priceRow }">
|
||||
<el-button class="btn-op-item" @click="handleDeletePriceInline(priceRow, agr)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
<el-button
|
||||
class="btn-op-item"
|
||||
:loading="deletingAgreementPriceId === priceRow.id"
|
||||
:disabled="!!deletingAgreementPriceId"
|
||||
@click="handleDeletePriceInline(priceRow, agr)"
|
||||
>
|
||||
<el-icon v-if="deletingAgreementPriceId !== priceRow.id"><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -861,23 +914,22 @@
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form v-if="priceSelectorMode === 'project'" label-position="top" class="price-selector-filter">
|
||||
<el-form-item label="选择项目价格体系">
|
||||
<el-select
|
||||
v-model="selectedProjectSystemId"
|
||||
placeholder="请选择项目价格体系"
|
||||
style="width: 100%"
|
||||
@change="handleProjectSystemChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="sys in projectSystemList"
|
||||
:key="sys.id"
|
||||
:label="sys.systemName"
|
||||
:value="sys.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="priceSelectorMode === 'project'" class="price-selector-filter price-selector-system-row">
|
||||
<span class="price-selector-system-label">选择项目价格体系</span>
|
||||
<el-select
|
||||
v-model="selectedProjectSystemId"
|
||||
class="price-selector-system-select"
|
||||
placeholder="请选择项目价格体系"
|
||||
@change="handleProjectSystemChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="sys in projectSystemList"
|
||||
:key="sys.id"
|
||||
:label="sys.systemName"
|
||||
:value="sys.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="price-selector-filter">
|
||||
<el-input
|
||||
v-model="priceSelectorKeyword"
|
||||
@@ -896,12 +948,17 @@
|
||||
v-loading="priceSelectorLoading"
|
||||
:data="filteredPriceSelectorList"
|
||||
border
|
||||
size="small"
|
||||
class="list-table"
|
||||
max-height="480"
|
||||
@selection-change="handlePriceSelectorChange"
|
||||
>
|
||||
<el-table-column type="selection" width="45" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="selection"
|
||||
width="45"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:selectable="priceSelectorRowSelectable"
|
||||
/>
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="projectCode" label="项目编码" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="cellTypeName" label="细胞类型" min-width="100" align="center" header-align="center" />
|
||||
@@ -921,10 +978,11 @@
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="priceSelectorSelected.length === 0"
|
||||
:loading="priceConfirmLoading"
|
||||
:disabled="priceSelectorSelected.length === 0 || priceConfirmLoading"
|
||||
@click="handleConfirmPriceSelector"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
<el-icon v-if="!priceConfirmLoading"><Check /></el-icon>
|
||||
确认添加({{ priceSelectorSelected.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -988,6 +1046,7 @@
|
||||
import ChannelViewDialog from './ChannelViewDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
fetchChannelList,
|
||||
fetchCreateChannel,
|
||||
@@ -1119,6 +1178,25 @@
|
||||
}>
|
||||
}
|
||||
|
||||
function resolveChannelAttachments(row: any) {
|
||||
if (Array.isArray(row?.attachmentFiles) && row.attachmentFiles.length) {
|
||||
return row.attachmentFiles.map((a: any, i: number) => ({
|
||||
id: a.id || `att_${i}`,
|
||||
fileName: a.fileName || '',
|
||||
fileUrl: a.fileUrl || '',
|
||||
fileSize: a.fileSize ?? 0,
|
||||
fileType: a.fileType || ''
|
||||
}))
|
||||
}
|
||||
if (Array.isArray(row?.attachments) && row.attachments.length) {
|
||||
return row.attachments.map((a: any) => ({ ...a }))
|
||||
}
|
||||
if (Array.isArray(row?.attachmentList) && row.attachmentList.length) {
|
||||
return row.attachmentList.map((a: any) => ({ ...a }))
|
||||
}
|
||||
return protocolUrlsToAttachments(String(row?.protocolAttachments || ''))
|
||||
}
|
||||
|
||||
const agreementTypeTagMap: Record<string, { label: string; type: string }> = {
|
||||
kyxy: { label: '框架协议', type: 'primary' },
|
||||
zsxy: { label: '中试协议', type: 'success' },
|
||||
@@ -1248,6 +1326,11 @@
|
||||
const agreementFormStatus = ref('dsx')
|
||||
const agreementSubmitLoading = ref(false)
|
||||
const agreementActivateLoading = ref(false)
|
||||
const agreementOperatingId = ref<string | null>(null)
|
||||
const agreementOperatingAction = ref<'activate' | 'void' | 'delete' | null>(null)
|
||||
const deletingInvoiceId = ref<string | null>(null)
|
||||
const deletingMailId = ref<string | null>(null)
|
||||
const priceConfirmLoading = ref(false)
|
||||
const agreementFormRef = ref()
|
||||
const agreementFormData = reactive({
|
||||
agreementType: '' as string,
|
||||
@@ -1278,6 +1361,48 @@
|
||||
const priceSelectorTitle = ref('从价格体系添加')
|
||||
const priceSelectorKeyword = ref('')
|
||||
const priceSelectorLoading = ref(false)
|
||||
const deletingAgreementPriceId = ref<string | null>(null)
|
||||
const deletingAgreementPriceAgreementId = ref<string | null>(null)
|
||||
|
||||
function isAgreementPriceTableLoading(agreementId: string) {
|
||||
return (
|
||||
deletingAgreementPriceAgreementId.value === agreementId &&
|
||||
deletingAgreementPriceId.value != null
|
||||
)
|
||||
}
|
||||
|
||||
function removeAgreementPriceFromMap(agreementId: string, priceId: string) {
|
||||
const list = agreementPricesMap.value[agreementId] || []
|
||||
agreementPricesMap.value = {
|
||||
...agreementPricesMap.value,
|
||||
[agreementId]: list.filter((item) => item.id !== priceId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 协议项目信息唯一键:项目编码 + 细胞类型 + 规格 */
|
||||
function buildAgreementPriceItemKey(item: Record<string, any>) {
|
||||
const code = String(item.projectCode || item.projectName || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const cellType = String(item.cellTypeCode || item.cellTypeName || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const spec = String(item.specText || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return `${code}|${cellType}|${spec}`
|
||||
}
|
||||
|
||||
function getAgreementPriceKeySet(agreementId: string | null) {
|
||||
if (!agreementId) return new Set<string>()
|
||||
const list = agreementPricesMap.value[agreementId] || []
|
||||
return new Set(list.map((item: any) => buildAgreementPriceItemKey(item)))
|
||||
}
|
||||
|
||||
function priceSelectorRowSelectable(row: any) {
|
||||
if (!currentAgreementId.value) return true
|
||||
return !getAgreementPriceKeySet(currentAgreementId.value).has(buildAgreementPriceItemKey(row))
|
||||
}
|
||||
|
||||
const filteredPriceSelectorList = computed(() => {
|
||||
const kw = priceSelectorKeyword.value.trim().toLowerCase()
|
||||
@@ -1380,13 +1505,7 @@
|
||||
formData.contactName = row.contactName || ''
|
||||
formData.contactPhone = row.contactPhone || ''
|
||||
formData.contactEmail = row.contactEmail || ''
|
||||
if (Array.isArray(row.attachments) && row.attachments.length) {
|
||||
formData.attachments = row.attachments.map((a: any) => ({ ...a }))
|
||||
} else if (Array.isArray(row.attachmentList) && row.attachmentList.length) {
|
||||
formData.attachments = row.attachmentList.map((a: any) => ({ ...a }))
|
||||
} else {
|
||||
formData.attachments = protocolUrlsToAttachments(String(row.protocolAttachments || ''))
|
||||
}
|
||||
formData.attachments = resolveChannelAttachments(row)
|
||||
formData.remark = row.remark || ''
|
||||
activeTab.value = 'basic'
|
||||
dialogVisible.value = true
|
||||
@@ -1468,10 +1587,16 @@
|
||||
// 主表删除
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该渠道吗?关联的子表数据也将清除。', '提示', { type: 'warning' })
|
||||
await fetchDeleteChannel(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该渠道吗?关联的子表数据也将清除。', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteChannel(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
@@ -1580,15 +1705,25 @@
|
||||
}
|
||||
|
||||
async function handleDeleteInvoice(row: any) {
|
||||
await ElMessageBox.confirm('确定删除该开票信息吗?', '提示', { type: 'warning' })
|
||||
if (editingId.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该开票信息吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!editingId.value) {
|
||||
const idx = invoiceList.value.findIndex((r: any) => r.id === row.id)
|
||||
if (idx !== -1) invoiceList.value.splice(idx, 1)
|
||||
return
|
||||
}
|
||||
deletingInvoiceId.value = row.id
|
||||
try {
|
||||
await fetchDeleteChannelInvoice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadSubTables(editingId.value)
|
||||
} else {
|
||||
// 本地模式:从本地数组中移除
|
||||
const idx = invoiceList.value.findIndex((r: any) => r.id === row.id)
|
||||
if (idx !== -1) invoiceList.value.splice(idx, 1)
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
deletingInvoiceId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1663,14 +1798,25 @@
|
||||
}
|
||||
|
||||
async function handleDeleteMail(row: any) {
|
||||
await ElMessageBox.confirm('确定删除该邮寄地址吗?', '提示', { type: 'warning' })
|
||||
if (editingId.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该邮寄地址吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!editingId.value) {
|
||||
const idx = mailList.value.findIndex((r: any) => r.id === row.id)
|
||||
if (idx !== -1) mailList.value.splice(idx, 1)
|
||||
return
|
||||
}
|
||||
deletingMailId.value = row.id
|
||||
try {
|
||||
await fetchDeleteChannelMailAddress(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadSubTables(editingId.value)
|
||||
} else {
|
||||
const idx = mailList.value.findIndex((r: any) => r.id === row.id)
|
||||
if (idx !== -1) mailList.value.splice(idx, 1)
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
deletingMailId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1786,17 +1932,43 @@
|
||||
}
|
||||
|
||||
async function handleDeleteAgreement(row: any) {
|
||||
await ElMessageBox.confirm('确定删除该协议吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteChannelAgreement(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该协议吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
agreementOperatingId.value = row.id
|
||||
agreementOperatingAction.value = 'delete'
|
||||
try {
|
||||
await fetchDeleteChannelAgreement(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
agreementOperatingId.value = null
|
||||
agreementOperatingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivateAgreement(row: any) {
|
||||
await ElMessageBox.confirm('确定将该协议设为生效中吗?', '协议生效', { type: 'info' })
|
||||
await fetchActivateChannelAgreement(row.id)
|
||||
ElMessage.success('生效成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
try {
|
||||
await ElMessageBox.confirm('确定将该协议设为生效中吗?', '协议生效', { type: 'info' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
agreementOperatingId.value = row.id
|
||||
agreementOperatingAction.value = 'activate'
|
||||
try {
|
||||
await fetchActivateChannelAgreement(row.id)
|
||||
ElMessage.success('生效成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
agreementOperatingId.value = null
|
||||
agreementOperatingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivateAgreementFromForm() {
|
||||
@@ -1825,10 +1997,23 @@
|
||||
}
|
||||
|
||||
async function handleVoidAgreement(row: any) {
|
||||
await ElMessageBox.confirm('确定作废该协议吗?作废后不可恢复。', '提示', { type: 'warning' })
|
||||
await fetchVoidChannelAgreement(row.id)
|
||||
ElMessage.success('操作成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
try {
|
||||
await ElMessageBox.confirm('确定作废该协议吗?作废后不可恢复。', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
agreementOperatingId.value = row.id
|
||||
agreementOperatingAction.value = 'void'
|
||||
try {
|
||||
await fetchVoidChannelAgreement(row.id)
|
||||
ElMessage.success('操作成功')
|
||||
await loadAgreementsForDialog(agreementChannelId.value!)
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
agreementOperatingId.value = null
|
||||
agreementOperatingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -1867,10 +2052,25 @@
|
||||
}
|
||||
|
||||
async function handleDeletePriceInline(priceRow: any, agreement: any) {
|
||||
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteChannelAgreementPrice(priceRow.id)
|
||||
ElMessage.success('删除成功')
|
||||
await refreshAgreementPrices(agreement.id)
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const agreementId = agreement.id
|
||||
const priceId = priceRow.id
|
||||
deletingAgreementPriceId.value = priceId
|
||||
deletingAgreementPriceAgreementId.value = agreementId
|
||||
try {
|
||||
await fetchDeleteChannelAgreementPrice(priceId)
|
||||
removeAgreementPriceFromMap(agreementId, priceId)
|
||||
ElMessage.success('删除成功')
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
deletingAgreementPriceId.value = null
|
||||
deletingAgreementPriceAgreementId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPriceCommand(command: string) {
|
||||
@@ -1881,7 +2081,6 @@
|
||||
} else if (command === 'fromProjectSystem') {
|
||||
priceSelectorMode.value = 'project'
|
||||
priceSelectorTitle.value = '从项目价格体系添加'
|
||||
selectedProjectSystemId.value = ''
|
||||
openProjectPriceSelector()
|
||||
}
|
||||
}
|
||||
@@ -1918,6 +2117,11 @@
|
||||
try {
|
||||
const res = await fetchProjectSystemList({ page: 1, pageSize: 100 })
|
||||
projectSystemList.value = (res as any)?.list || []
|
||||
const first = projectSystemList.value[0]
|
||||
if (first?.id) {
|
||||
selectedProjectSystemId.value = first.id
|
||||
await handleProjectSystemChange(first.id)
|
||||
}
|
||||
} catch {
|
||||
projectSystemList.value = []
|
||||
}
|
||||
@@ -1953,37 +2157,85 @@
|
||||
|
||||
async function handleConfirmPriceSelector() {
|
||||
if (!currentAgreementId.value) return
|
||||
const existingList = agreementPricesMap.value[currentAgreementId.value] || []
|
||||
const existingIds = new Set(existingList.map((item: any) => String(item.sourcePriceId || '')).filter(Boolean))
|
||||
const existingKeys = getAgreementPriceKeySet(currentAgreementId.value)
|
||||
const batchKeys = new Set<string>()
|
||||
const toAdd: any[] = []
|
||||
let skippedCount = 0
|
||||
|
||||
for (const row of priceSelectorSelected.value) {
|
||||
if (existingIds.has(row.id)) continue
|
||||
try {
|
||||
await fetchCreateChannelAgreementPrice(currentAgreementId.value, {
|
||||
projectName: row.projectName || '',
|
||||
projectCode: row.projectCode || '',
|
||||
cellTypeCode: row.cellTypeCode || undefined,
|
||||
cellTypeName: row.cellTypeName || '',
|
||||
specText: row.specText || '',
|
||||
originalPrice: row.unitPrice ?? null,
|
||||
agreementPrice: null,
|
||||
remark: row.remark || undefined
|
||||
})
|
||||
} catch {
|
||||
/* */
|
||||
const key = buildAgreementPriceItemKey(row)
|
||||
if (existingKeys.has(key) || batchKeys.has(key)) {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
batchKeys.add(key)
|
||||
toAdd.push(row)
|
||||
}
|
||||
priceSelectorVisible.value = false
|
||||
if (currentAgreementId.value) {
|
||||
await refreshAgreementPrices(currentAgreementId.value)
|
||||
|
||||
if (!toAdd.length) {
|
||||
ElMessage.warning(
|
||||
skippedCount > 0 ? '所选项目均已存在,不可重复添加' : '请选择要添加的项目'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
priceConfirmLoading.value = true
|
||||
let successCount = 0
|
||||
try {
|
||||
for (const row of toAdd) {
|
||||
try {
|
||||
await fetchCreateChannelAgreementPrice(currentAgreementId.value, {
|
||||
projectName: row.projectName || '',
|
||||
projectCode: row.projectCode || '',
|
||||
cellTypeCode: row.cellTypeCode || undefined,
|
||||
cellTypeName: row.cellTypeName || '',
|
||||
specText: row.specText || '',
|
||||
originalPrice: row.unitPrice ?? null,
|
||||
agreementPrice: null,
|
||||
remark: row.remark || undefined
|
||||
})
|
||||
successCount += 1
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`成功添加 ${successCount} 条项目`)
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
ElMessage.warning(`已跳过 ${skippedCount} 条重复项目`)
|
||||
}
|
||||
priceSelectorVisible.value = false
|
||||
if (currentAgreementId.value) {
|
||||
await refreshAgreementPrices(currentAgreementId.value)
|
||||
}
|
||||
} finally {
|
||||
priceConfirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePrice(row: any) {
|
||||
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteChannelAgreementPrice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
if (currentAgreementId.value) {
|
||||
await refreshAgreementPrices(currentAgreementId.value)
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该价格明细吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const agreementId = currentAgreementId.value
|
||||
if (!agreementId) return
|
||||
const priceId = row.id
|
||||
deletingAgreementPriceId.value = priceId
|
||||
deletingAgreementPriceAgreementId.value = agreementId
|
||||
try {
|
||||
await fetchDeleteChannelAgreementPrice(priceId)
|
||||
removeAgreementPriceFromMap(agreementId, priceId)
|
||||
priceList.value = priceList.value.filter((item) => item.id !== priceId)
|
||||
ElMessage.success('删除成功')
|
||||
} catch {
|
||||
/* 全局已提示 */
|
||||
} finally {
|
||||
deletingAgreementPriceId.value = null
|
||||
deletingAgreementPriceAgreementId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2083,15 +2335,7 @@
|
||||
}
|
||||
|
||||
function handleDownloadAttachment(file: any) {
|
||||
const url = fullFileUrl(file.fileUrl)
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.fileName || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2648,6 +2892,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.price-selector-system-row {
|
||||
align-items: center;
|
||||
|
||||
.price-selector-system-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--mint-text, #334155);
|
||||
line-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.price-selector-system-select {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -227,6 +227,7 @@
|
||||
fetchDeleteCheckupInstitutionPackage,
|
||||
fetchCheckupInstitutionProjects
|
||||
} from '@/api/admin'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
const props = defineProps<{
|
||||
institutionId: string
|
||||
@@ -374,10 +375,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteCheckupInstitutionPackage(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadPackages()
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCheckupInstitutionPackage(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadPackages()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function openProjectSelector() {
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
fetchUpdateCheckupInstitutionProject,
|
||||
fetchDeleteCheckupInstitutionProject
|
||||
} from '@/api/admin'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
defineOptions({ name: 'PilotCheckupInstitution' })
|
||||
|
||||
@@ -555,12 +556,18 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await fetchDeleteCheckupInstitution(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCheckupInstitution(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -693,10 +700,16 @@
|
||||
}
|
||||
|
||||
async function handleDeleteItem(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteCheckupInstitutionProject(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadItems()
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteCheckupInstitutionProject(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
@@ -523,10 +523,16 @@
|
||||
|
||||
// --- Delete ---
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
|
||||
await fetchDeleteProjectSystem(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteProjectSystem(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
// --- Batch delete ---
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
@@ -125,8 +126,11 @@
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -145,11 +149,90 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<el-dialog
|
||||
v-model="viewVisible"
|
||||
title="查看耗材包"
|
||||
width="960px"
|
||||
class="dialog-form-layout pack-view-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div v-loading="viewLoading" class="pack-view-body">
|
||||
<template v-if="viewDetail">
|
||||
<div class="form-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="pack-view-kv-grid">
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">编号</span>
|
||||
<span class="pack-view-value">{{ viewDetail.packCode || '—' }}</span>
|
||||
</div>
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">名称</span>
|
||||
<span class="pack-view-value">{{ viewDetail.packName || '—' }}</span>
|
||||
</div>
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">使用场景</span>
|
||||
<span class="pack-view-value">{{ viewDetail.usageScene || '—' }}</span>
|
||||
</div>
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">类型</span>
|
||||
<span class="pack-view-value">{{ packTypeLabel(viewDetail.packType) }}</span>
|
||||
</div>
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">状态</span>
|
||||
<span class="pack-view-value">
|
||||
<el-tag :type="viewDetail.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ viewDetail.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pack-view-kv">
|
||||
<span class="pack-view-label">备注</span>
|
||||
<span class="pack-view-value">{{ viewDetail.remark || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="section-title">耗材明细</div>
|
||||
<el-table :data="viewDetail.items || []" stripe class="list-table inner-table">
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="56"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => index + 1"
|
||||
/>
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="standardUsage" label="标准用量" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="140" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!(viewDetail.items || []).length"
|
||||
description="暂无耗材明细"
|
||||
:image-size="48"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="viewVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
width="960px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
@@ -167,7 +250,7 @@
|
||||
<!-- 基本信息 -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="form-grid-2">
|
||||
<div class="form-grid-3">
|
||||
<el-form-item label="编号" prop="packCode">
|
||||
<el-input v-model="formData.packCode" placeholder="请输入编号" />
|
||||
</el-form-item>
|
||||
@@ -201,16 +284,33 @@
|
||||
添加耗材
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="formData.items" stripe size="small" class="inner-table">
|
||||
<el-table :data="formData.items" stripe class="inner-table">
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="56"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => index + 1"
|
||||
/>
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="标准用量" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
|
||||
<el-input
|
||||
v-model="row.standardUsage"
|
||||
placeholder="用量"
|
||||
class="inner-cell-input inner-cell-input--usage"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column label="备注" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="备注" class="inner-cell-input" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="80" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="formData.items.splice($index, 1)">
|
||||
@@ -244,29 +344,42 @@
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="consumableKeyword"
|
||||
placeholder="搜索耗材编码/名称"
|
||||
clearable
|
||||
style="margin-bottom: 12px"
|
||||
@input="filterConsumables"
|
||||
/>
|
||||
<div class="consumable-select-toolbar">
|
||||
<el-input
|
||||
v-model="consumableKeyword"
|
||||
placeholder="搜索耗材编码/名称"
|
||||
clearable
|
||||
class="consumable-select-search"
|
||||
@input="filterConsumables"
|
||||
/>
|
||||
<span class="consumable-select-count">
|
||||
已选 <span class="sel-num">{{ selectedConsumables.length }}</span> 项
|
||||
</span>
|
||||
</div>
|
||||
<el-table
|
||||
ref="consumableSelectTableRef"
|
||||
class="list-table consumable-select-table"
|
||||
:data="filteredConsumableList"
|
||||
stripe
|
||||
size="small"
|
||||
height="360"
|
||||
highlight-current-row
|
||||
@current-change="handleConsumableSelect"
|
||||
row-key="id"
|
||||
@selection-change="handleConsumableSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" header-align="center" :reserve-selection="true" />
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
|
||||
确定
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="selectedConsumables.length === 0"
|
||||
@click="confirmAddConsumable"
|
||||
>
|
||||
确定添加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -275,7 +388,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Close, Check } from '@element-plus/icons-vue'
|
||||
import { Plus, Close, Check, View } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchConsumablePackList,
|
||||
fetchConsumablePackDetail,
|
||||
@@ -284,6 +397,7 @@
|
||||
fetchDeleteConsumablePack,
|
||||
fetchAllConsumables
|
||||
} from '@/api/production'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
defineOptions({ name: 'ProductionConsumablePack' })
|
||||
|
||||
@@ -298,6 +412,15 @@
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewLoading = ref(false)
|
||||
const viewDetail = ref<any>(null)
|
||||
|
||||
function packTypeLabel(packType?: string) {
|
||||
if (packType === 'bzhcb') return '标准耗材包'
|
||||
if (packType === 'zdyhcb') return '自定义耗材包'
|
||||
return packType || '—'
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
packCode: '',
|
||||
@@ -320,7 +443,8 @@
|
||||
const allConsumableList = ref<any[]>([])
|
||||
const filteredConsumableList = ref<any[]>([])
|
||||
const consumableKeyword = ref('')
|
||||
const selectedConsumable = ref<any>(null)
|
||||
const consumableSelectTableRef = ref()
|
||||
const selectedConsumables = ref<any[]>([])
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
@@ -371,13 +495,28 @@
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
async function handleView(row: any) {
|
||||
viewVisible.value = true
|
||||
viewLoading.value = true
|
||||
viewDetail.value = null
|
||||
try {
|
||||
viewDetail.value = await fetchConsumablePackDetail(row.id)
|
||||
} catch {
|
||||
ElMessage.error('加载详情失败')
|
||||
viewVisible.value = false
|
||||
} finally {
|
||||
viewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑耗材包'
|
||||
editingId.value = row.id
|
||||
const packId = row.id
|
||||
resetForm()
|
||||
editingId.value = packId
|
||||
try {
|
||||
const detail = await fetchConsumablePackDetail(row.id)
|
||||
const detail = await fetchConsumablePackDetail(packId)
|
||||
formData.packCode = detail.packCode || ''
|
||||
formData.packName = detail.packName || ''
|
||||
formData.usageScene = detail.usageScene || ''
|
||||
@@ -409,6 +548,7 @@
|
||||
consumableName: item.consumableName,
|
||||
specModel: item.specModel,
|
||||
standardUsage: item.standardUsage,
|
||||
unit: item.unit || undefined,
|
||||
remark: item.remark || ''
|
||||
}))
|
||||
}
|
||||
@@ -428,27 +568,39 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteConsumablePack(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteConsumablePack(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材包吗?`, '提示', { type: 'warning' })
|
||||
for (const id of ids) {
|
||||
await fetchDeleteConsumablePack(id)
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材包吗?`, '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteConsumablePack(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
// 选择耗材弹窗
|
||||
async function openAddConsumableDialog() {
|
||||
selectedConsumable.value = null
|
||||
selectedConsumables.value = []
|
||||
consumableKeyword.value = ''
|
||||
if (allConsumableList.value.length === 0) {
|
||||
try {
|
||||
@@ -459,6 +611,7 @@
|
||||
}
|
||||
filteredConsumableList.value = [...allConsumableList.value]
|
||||
selectConsumableVisible.value = true
|
||||
nextTick(() => consumableSelectTableRef.value?.clearSelection?.())
|
||||
}
|
||||
|
||||
function filterConsumables() {
|
||||
@@ -474,30 +627,41 @@
|
||||
)
|
||||
}
|
||||
|
||||
function handleConsumableSelect(row: any) {
|
||||
selectedConsumable.value = row
|
||||
function handleConsumableSelectionChange(rows: any[]) {
|
||||
selectedConsumables.value = rows
|
||||
}
|
||||
|
||||
function confirmAddConsumable() {
|
||||
if (!selectedConsumable.value) return
|
||||
const c = selectedConsumable.value
|
||||
// 检查是否已添加
|
||||
const exists = formData.items.some(
|
||||
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
|
||||
)
|
||||
if (exists) {
|
||||
ElMessage.warning('该耗材已在明细中')
|
||||
return
|
||||
if (!selectedConsumables.value.length) return
|
||||
let added = 0
|
||||
let skipped = 0
|
||||
for (const c of selectedConsumables.value) {
|
||||
const exists = formData.items.some(
|
||||
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
|
||||
)
|
||||
if (exists) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
formData.items.push({
|
||||
consumableId: c.id,
|
||||
consumableCode: c.consumableCode || '',
|
||||
consumableName: c.consumableName || '',
|
||||
specModel: c.specModel || '',
|
||||
standardUsage: c.standardUsage || '',
|
||||
unit: c.unit || '',
|
||||
remark: ''
|
||||
})
|
||||
added++
|
||||
}
|
||||
if (added > 0) {
|
||||
ElMessage.success(
|
||||
skipped > 0 ? `已添加 ${added} 项,${skipped} 项重复已跳过` : `已添加 ${added} 项耗材`
|
||||
)
|
||||
selectConsumableVisible.value = false
|
||||
} else {
|
||||
ElMessage.warning('所选耗材均已在明细中')
|
||||
}
|
||||
formData.items.push({
|
||||
consumableId: c.id,
|
||||
consumableCode: c.consumableCode || '',
|
||||
consumableName: c.consumableName || '',
|
||||
specModel: c.specModel || '',
|
||||
standardUsage: '',
|
||||
remark: ''
|
||||
})
|
||||
selectConsumableVisible.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -533,5 +697,80 @@
|
||||
|
||||
.inner-table {
|
||||
width: 100%;
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.inner-cell-input) {
|
||||
.el-input__wrapper {
|
||||
min-height: 36px;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.el-input__inner {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.inner-cell-input--usage .el-input__inner) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.consumable-select-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.consumable-select-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.consumable-select-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
|
||||
.sel-num {
|
||||
color: var(--primary, #10b981);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pack-view-body {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.pack-view-kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px 24px;
|
||||
}
|
||||
|
||||
.pack-view-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pack-view-label {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pack-view-value {
|
||||
font-size: 14px;
|
||||
color: var(--mint-text, #1e293b);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>耗材管理</h1>
|
||||
<p class="page-desc">维护生产耗材的编码、名称、规格及标准用量等信息。</p>
|
||||
<p class="page-desc">维护生产耗材的编码、名称、规格、标准用量及单位等信息。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
@@ -72,6 +72,7 @@
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="90" align="center" header-align="center" />
|
||||
<el-table-column label="描述" min-width="160" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ truncateText(row.description, 20) }}
|
||||
@@ -132,6 +133,16 @@
|
||||
<el-form-item label="标准用量">
|
||||
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
|
||||
<el-option
|
||||
v-for="item in unitOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="form-col-span-2">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述" />
|
||||
</el-form-item>
|
||||
@@ -175,9 +186,12 @@
|
||||
fetchUpdateConsumable,
|
||||
fetchDeleteConsumable
|
||||
} from '@/api/production'
|
||||
import { fetchItemUnitList } from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'ProductionConsumable' })
|
||||
|
||||
const unitOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
function truncateText(text: string, max: number) {
|
||||
if (!text) return ''
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
@@ -202,6 +216,7 @@
|
||||
consumableName: '',
|
||||
specModel: '',
|
||||
standardUsage: '',
|
||||
unit: '',
|
||||
description: '',
|
||||
remark: ''
|
||||
})
|
||||
@@ -226,6 +241,7 @@
|
||||
{ label: '耗材名称', value: row.consumableName },
|
||||
{ label: '规格型号', value: row.specModel },
|
||||
{ label: '标准用量', value: row.standardUsage },
|
||||
{ label: '单位', value: row.unit },
|
||||
{ label: '描述', value: row.description, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
@@ -264,6 +280,7 @@
|
||||
formData.consumableName = ''
|
||||
formData.specModel = ''
|
||||
formData.standardUsage = ''
|
||||
formData.unit = ''
|
||||
formData.description = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
@@ -285,6 +302,7 @@
|
||||
formData.consumableName = row.consumableName || ''
|
||||
formData.specModel = row.specModel || ''
|
||||
formData.standardUsage = row.standardUsage ?? ''
|
||||
formData.unit = row.unit || ''
|
||||
formData.description = row.description || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
@@ -300,6 +318,7 @@
|
||||
consumableName: formData.consumableName.trim(),
|
||||
specModel: formData.specModel?.trim() || undefined,
|
||||
standardUsage: formData.standardUsage ?? undefined,
|
||||
unit: formData.unit?.trim() || undefined,
|
||||
description: formData.description?.trim() || undefined,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
@@ -324,10 +343,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteConsumable(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteConsumable(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
@@ -344,8 +369,10 @@
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
loadData()
|
||||
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
|
||||
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -288,10 +288,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteOutputType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteOutputType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
|
||||
@@ -288,10 +288,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteProcessCategory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteProcessCategory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -343,10 +343,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteReagent(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteReagent(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
|
||||
@@ -129,29 +129,75 @@
|
||||
<el-input v-model="formData.versionNo" placeholder="请输入版本号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="附件" class="form-col-span-2">
|
||||
<div class="upload-card-row">
|
||||
<div
|
||||
v-for="(url, idx) in attachmentList"
|
||||
:key="idx"
|
||||
class="upload-card-item"
|
||||
>
|
||||
<el-icon class="upload-card-icon"><Document /></el-icon>
|
||||
<span class="upload-card-name" :title="getFileName(url)">{{ getFileName(url) }}</span>
|
||||
<el-icon class="upload-card-remove" @click="removeAttachment(idx)"><Close /></el-icon>
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="*"
|
||||
>
|
||||
<div class="upload-card-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传</span>
|
||||
<div class="attachment-upload">
|
||||
<div class="attachment-list">
|
||||
<el-upload
|
||||
ref="attachmentUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:multiple="true"
|
||||
accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
@change="handleAttachmentChange"
|
||||
>
|
||||
<div class="upload-card">
|
||||
<el-icon :size="28" color="#10b981"><Plus /></el-icon>
|
||||
<span class="upload-card-text">上传附件</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-for="(file, index) in formData.attachments"
|
||||
:key="file.id || index"
|
||||
class="attachment-item"
|
||||
:class="{ 'is-uploading': file._uploading }"
|
||||
>
|
||||
<div
|
||||
class="attachment-preview"
|
||||
@click="
|
||||
isImageFile(file) && file.fileUrl
|
||||
? handlePreviewAttachment(file)
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<template v-if="isImageFile(file) && file.fileUrl">
|
||||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||||
</template>
|
||||
<template v-else-if="isImageFile(file) && !file.fileUrl">
|
||||
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
|
||||
</template>
|
||||
<template v-else-if="isVideoFile(file)">
|
||||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||||
<el-icon :size="28"><Document /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="attachment-footer">
|
||||
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
|
||||
<div class="attachment-actions">
|
||||
<el-icon
|
||||
class="action-btn"
|
||||
title="下载"
|
||||
@click="handleDownloadAttachment(file)"
|
||||
><Download
|
||||
/></el-icon>
|
||||
<el-icon
|
||||
class="action-btn action-btn--danger"
|
||||
title="删除"
|
||||
@click="removeAttachment(index)"
|
||||
><Delete
|
||||
/></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<span class="upload-hint"
|
||||
>支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
|
||||
>
|
||||
</div>
|
||||
<div class="upload-card-hint">支持上传多个附件文件。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
@@ -170,21 +216,104 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
title="文件预览"
|
||||
width="800px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="previewType === 'image'" class="preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="预览"
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.sopName || 'SOP详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
>
|
||||
<template #append>
|
||||
<div v-if="viewRow" class="view-section sop-view-attachment-section">
|
||||
<h3>附件</h3>
|
||||
<div v-if="viewAttachments.length" class="attachment-list">
|
||||
<div
|
||||
v-for="(file, index) in viewAttachments"
|
||||
:key="file.id || index"
|
||||
class="attachment-item attachment-item--view"
|
||||
>
|
||||
<div
|
||||
class="attachment-preview"
|
||||
@click="
|
||||
isImageFile(file) || isVideoFile(file)
|
||||
? handlePreviewAttachment(file)
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<template v-if="isImageFile(file)">
|
||||
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
|
||||
</template>
|
||||
<template v-else-if="isVideoFile(file)">
|
||||
<div class="attachment-preview__icon attachment-preview__icon--video">
|
||||
<el-icon :size="28"><VideoPlay /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-preview__icon attachment-preview__icon--doc">
|
||||
<el-icon :size="28"><Document /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="attachment-footer">
|
||||
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
|
||||
<div class="attachment-actions">
|
||||
<el-icon
|
||||
class="action-btn"
|
||||
title="下载"
|
||||
@click.stop="handleDownloadAttachment(file)"
|
||||
>
|
||||
<Download />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="sop-view-attachment-empty">暂无附件</p>
|
||||
</div>
|
||||
</template>
|
||||
</SystemViewDialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal, Document } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Plus,
|
||||
Close,
|
||||
Check,
|
||||
View,
|
||||
ScaleToOriginal,
|
||||
Document,
|
||||
Download,
|
||||
Delete,
|
||||
VideoPlay,
|
||||
Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
@@ -194,9 +323,18 @@
|
||||
fetchDeleteSop
|
||||
} from '@/api/production'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
defineOptions({ name: 'ProductionSop' })
|
||||
|
||||
type SopAttachment = {
|
||||
id: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileType?: string
|
||||
_uploading?: boolean
|
||||
}
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
@@ -210,18 +348,19 @@
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
const attachmentUploadRef = ref()
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video'>('image')
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
const formData = reactive({
|
||||
sopCode: '',
|
||||
sopName: '',
|
||||
versionNo: '',
|
||||
sopAttachments: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const attachmentList = computed(() => {
|
||||
if (!formData.sopAttachments) return []
|
||||
return formData.sopAttachments.split(',').filter(Boolean)
|
||||
remark: '',
|
||||
attachments: [] as SopAttachment[]
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -229,27 +368,89 @@
|
||||
sopName: [{ required: true, message: '请输入SOP文档名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function fullFileUrl(suffix: string) {
|
||||
if (!suffix) return ''
|
||||
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/'))
|
||||
return suffix
|
||||
return `${minioBaseUrl}/${suffix}`
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
if (!url) return ''
|
||||
const parts = url.split('/')
|
||||
return decodeURIComponent(parts[parts.length - 1] || '')
|
||||
}
|
||||
|
||||
function attachmentsFromUrls(urls: string): SopAttachment[] {
|
||||
if (!urls) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((url, i) => ({
|
||||
id: `existing_${i}`,
|
||||
fileName: getFileName(url),
|
||||
fileUrl: url,
|
||||
fileType: ''
|
||||
}))
|
||||
}
|
||||
|
||||
function attachmentsFromApiFiles(files: any[]): SopAttachment[] {
|
||||
if (!files?.length) return []
|
||||
return files.map((f, i) => ({
|
||||
id: f.id || `existing_${i}`,
|
||||
fileName: f.fileName || getFileName(f.fileUrl || ''),
|
||||
fileUrl: f.fileUrl || '',
|
||||
fileType: f.fileType || ''
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveAttachments(row: any): SopAttachment[] {
|
||||
if (row?.attachmentFiles?.length) {
|
||||
return attachmentsFromApiFiles(row.attachmentFiles)
|
||||
}
|
||||
return attachmentsFromUrls(row?.sopAttachments || '')
|
||||
}
|
||||
|
||||
function syncSopAttachmentsFromList() {
|
||||
formData.sopAttachments = formData.attachments
|
||||
.filter((a) => a.fileUrl && !a._uploading)
|
||||
.map((a) => a.fileUrl)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function isImageFile(file: SopAttachment) {
|
||||
const url = (file.fileUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('image/')
|
||||
)
|
||||
}
|
||||
|
||||
function isVideoFile(file: SopAttachment) {
|
||||
const url = (file.fileUrl || '').toLowerCase()
|
||||
const name = (file.fileName || '').toLowerCase()
|
||||
return (
|
||||
/\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
|
||||
/\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
|
||||
(file.fileType || '').startsWith('video/')
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewAttachments = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
return resolveAttachments(viewRow.value)
|
||||
})
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
const attachCount = row.sopAttachments ? row.sopAttachments.split(',').filter(Boolean).length : 0
|
||||
const attachLinks = row.sopAttachments
|
||||
? row.sopAttachments
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((url: string) => getFileName(url))
|
||||
.join('、')
|
||||
: '无'
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
@@ -258,16 +459,33 @@
|
||||
{ label: 'SOP文档名称', value: row.sopName },
|
||||
{ label: '版本号', value: row.versionNo },
|
||||
{ label: '附件数', value: String(attachCount) },
|
||||
{ label: '附件列表', value: attachLinks, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function handleFileChange(uploadFile: any) {
|
||||
const file = uploadFile?.raw
|
||||
async function handleAttachmentChange(uploadFile: any) {
|
||||
const file = uploadFile?.raw || uploadFile
|
||||
if (!file) return
|
||||
|
||||
const maxSizeMb = 20
|
||||
if (file.size / 1024 / 1024 > maxSizeMb) {
|
||||
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
|
||||
attachmentUploadRef.value?.clearFiles()
|
||||
return
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
formData.attachments.push({
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
fileUrl: '',
|
||||
fileType: file.type || '',
|
||||
_uploading: true
|
||||
})
|
||||
const idx = formData.attachments.length - 1
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('business_type', 'sop')
|
||||
@@ -275,19 +493,40 @@
|
||||
fd.append('file', file)
|
||||
const res: any = await fetchUploadAttachment(fd)
|
||||
if (res?.fileUrl) {
|
||||
const current = formData.sopAttachments ? formData.sopAttachments.split(',').filter(Boolean) : []
|
||||
current.push(res.fileUrl)
|
||||
formData.sopAttachments = current.join(',')
|
||||
formData.attachments[idx] = {
|
||||
...formData.attachments[idx],
|
||||
id: res.id || tempId,
|
||||
fileName: file.name || res.fileName,
|
||||
fileUrl: res.fileUrl,
|
||||
fileType: res.fileType || file.type || '',
|
||||
_uploading: false
|
||||
}
|
||||
syncSopAttachmentsFromList()
|
||||
} else {
|
||||
formData.attachments.splice(idx, 1)
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('文件上传失败')
|
||||
formData.attachments.splice(idx, 1)
|
||||
} finally {
|
||||
nextTick(() => attachmentUploadRef.value?.clearFiles())
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(idx: number) {
|
||||
const current = formData.sopAttachments.split(',').filter(Boolean)
|
||||
current.splice(idx, 1)
|
||||
formData.sopAttachments = current.join(',')
|
||||
function removeAttachment(index: number) {
|
||||
formData.attachments.splice(index, 1)
|
||||
syncSopAttachmentsFromList()
|
||||
}
|
||||
|
||||
function handlePreviewAttachment(file: SopAttachment) {
|
||||
if (file._uploading) return
|
||||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function handleDownloadAttachment(file: SopAttachment) {
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -322,6 +561,7 @@
|
||||
formData.versionNo = ''
|
||||
formData.sopAttachments = ''
|
||||
formData.remark = ''
|
||||
formData.attachments = []
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
@@ -341,6 +581,7 @@
|
||||
formData.sopName = row.sopName || ''
|
||||
formData.versionNo = row.versionNo || ''
|
||||
formData.sopAttachments = row.sopAttachments || ''
|
||||
formData.attachments = resolveAttachments(row)
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
@@ -348,6 +589,7 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
syncSopAttachmentsFromList()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
@@ -378,10 +620,16 @@
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteSop(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await withFullscreenLoading(async () => {
|
||||
await fetchDeleteSop(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.delete)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
@@ -410,83 +658,208 @@
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
.attachment-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-card-item {
|
||||
width: 120px;
|
||||
height: 96px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.upload-card-icon {
|
||||
font-size: 24px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-card-name {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-card-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
font-size: 14px;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-card-trigger {
|
||||
width: 120px;
|
||||
.upload-card {
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: border-color 0.2s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
border-style: solid;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card-hint {
|
||||
.upload-card-text {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
color: #10b981;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.is-uploading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-preview__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--video {
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&--doc {
|
||||
background: #e0f2fe;
|
||||
color: #0284c7;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
min-height: calc(11px * 1.4 * 2);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
background: #f1f5f9;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--danger:hover {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sop-view-attachment-section {
|
||||
padding: 18px 22px;
|
||||
border-bottom: none;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
padding-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
border-left: 3px solid #10b981;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.sop-view-attachment-empty {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.attachment-item--view {
|
||||
.attachment-preview {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.attachment-preview:has(img),
|
||||
.attachment-preview:has(.attachment-preview__icon--video) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -148,7 +148,21 @@
|
||||
:icon="Tickets"
|
||||
:meta="viewDetail?.taskNo || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
>
|
||||
<template #append>
|
||||
<div
|
||||
v-if="viewDetail?.attachmentFiles?.length"
|
||||
class="view-section qc-task-view-attachments"
|
||||
>
|
||||
<h3>检测材料</h3>
|
||||
<ProtocolAttachmentReadonly
|
||||
:files="viewDetail.attachmentFiles"
|
||||
@preview="handleViewAttachmentPreview"
|
||||
@download="handleViewAttachmentDownload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SystemViewDialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -158,6 +172,7 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Check, Search, RefreshLeft, VideoPlay, Upload, View, Tickets, EditPen } from '@element-plus/icons-vue'
|
||||
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import {
|
||||
@@ -175,6 +190,7 @@
|
||||
} from '@/api/qc-task'
|
||||
import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
|
||||
import { taskTypeLabel, taskStatusLabel } from '@/utils/qc-labels'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionQcTask' })
|
||||
|
||||
@@ -199,6 +215,35 @@
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const viewDetail = ref<QcTaskDetail | null>(null)
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
function apiFilesToFinanceItems(
|
||||
files?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
|
||||
): FinanceAttachmentItem[] {
|
||||
if (!files?.length) return []
|
||||
return files.map((f, i) => ({
|
||||
id: f.id || `att_${i}`,
|
||||
fileName: f.fileName || '',
|
||||
fileUrl: f.fileUrl || '',
|
||||
fileType: f.fileType || ''
|
||||
}))
|
||||
}
|
||||
|
||||
function attachmentFullUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return ''
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
|
||||
}
|
||||
|
||||
function handleViewAttachmentPreview(file: Record<string, unknown>) {
|
||||
const url = attachmentFullUrl(String(file.fileUrl || ''))
|
||||
if (url) window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function handleViewAttachmentDownload(file: Record<string, unknown>) {
|
||||
downloadAttachmentFile(file, { fullFileUrl: attachmentFullUrl })
|
||||
}
|
||||
|
||||
const viewSections = computed<ViewSection[]>(() => {
|
||||
const d = viewDetail.value
|
||||
@@ -272,11 +317,19 @@
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openAttach(row: QcTask) {
|
||||
async function openAttach(row: QcTask) {
|
||||
attachTaskId.value = row.id
|
||||
attachRemark.value = ''
|
||||
attachments.value = []
|
||||
attachRemark.value = row.remark || ''
|
||||
attachments.value = apiFilesToFinanceItems(row.attachmentFiles)
|
||||
attachVisible.value = true
|
||||
if (attachments.value.length) return
|
||||
try {
|
||||
const detail = await fetchQcTaskDetail(row.id)
|
||||
attachRemark.value = detail.remark || ''
|
||||
attachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
|
||||
} catch {
|
||||
/* 列表未带附件时拉详情 */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAttachments() {
|
||||
@@ -313,10 +366,15 @@
|
||||
async function openResult(row: QcTask) {
|
||||
resultTaskId.value = row.id
|
||||
resultTaskType.value = row.taskType
|
||||
resultRemark.value = ''
|
||||
resultAttachments.value = []
|
||||
resultRemark.value = row.remark || ''
|
||||
resultAttachments.value = apiFilesToFinanceItems(row.attachmentFiles)
|
||||
resultLoading.value = true
|
||||
try {
|
||||
if (row.taskType === 'dsfjc' && !resultAttachments.value.length) {
|
||||
const detail = await fetchQcTaskDetail(row.id)
|
||||
resultRemark.value = detail.remark || ''
|
||||
resultAttachments.value = apiFilesToFinanceItems(detail.attachmentFiles)
|
||||
}
|
||||
const results = await fetchQcTaskResults(row.id)
|
||||
if (results && results.length > 0) {
|
||||
resultParams.value = results
|
||||
@@ -379,4 +437,19 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.qc-task-view-attachments {
|
||||
padding: 18px 22px;
|
||||
border-bottom: none;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
padding-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
border-left: 3px solid #10b981;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -493,6 +493,7 @@
|
||||
type FinanceAttachmentItem
|
||||
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionSampleReception' })
|
||||
|
||||
@@ -620,14 +621,7 @@
|
||||
}
|
||||
|
||||
function handleAttachmentDownload(file: Record<string, unknown>) {
|
||||
const url = String(file.fileUrl || '')
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = fullFileUrl(url)
|
||||
a.download = String(file.fileName || 'download')
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
a.click()
|
||||
downloadAttachmentFile(file, { fullFileUrl })
|
||||
}
|
||||
|
||||
function getCurrentUserHandler() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 捐赠协议单审核 -->
|
||||
<!-- 捐赠协议审核 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
@@ -9,24 +9,27 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">捐赠协议审核</h2>
|
||||
<h2 class="title-bar-heading">捐赠协议单列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/母亲姓名" clearable />
|
||||
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-form-item label="协议状态">
|
||||
<el-select
|
||||
v-model="searchForm.auditStatus"
|
||||
v-model="searchForm.donationStatus"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="审核通过" value="shtg" />
|
||||
<el-option label="审核驳回" value="shth" />
|
||||
<el-option label="待生效" value="dsx" />
|
||||
<el-option label="生效中" value="sxz" />
|
||||
<el-option label="已过期" value="ygq" />
|
||||
<el-option label="已作废" value="yzf" />
|
||||
<el-option label="转细胞存储" value="zckc" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -49,19 +52,45 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shtg')">
|
||||
<el-button type="primary" class="btn-main" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openAuditDialog()">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
批量审核通过
|
||||
审核
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shth')">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
批量审核驳回
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="!selectedRows.length"
|
||||
@click="openStatusChangeDialog('activate')"
|
||||
>
|
||||
协议生效
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('activate')">
|
||||
批量生效
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="!selectedRows.length"
|
||||
@click="openStatusChangeDialog('expire')"
|
||||
>
|
||||
协议过期
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('void')">
|
||||
批量作废
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="!selectedRows.length"
|
||||
@click="openStatusChangeDialog('void')"
|
||||
>
|
||||
协议作废
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="!selectedRows.length"
|
||||
@click="openStatusChangeDialog('toStorage')"
|
||||
>
|
||||
转细胞存储
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +121,24 @@
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationStatus"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
donationStatusLabel(String(row.donationStatus || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="donationDate"
|
||||
label="捐赠日期"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="motherName"
|
||||
label="母亲姓名"
|
||||
@@ -100,39 +147,100 @@
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditStatus"
|
||||
label="审核状态"
|
||||
width="120"
|
||||
prop="motherPhone"
|
||||
label="母亲电话"
|
||||
min-width="130"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="motherIdCard"
|
||||
label="母亲身份证号"
|
||||
min-width="190"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="fatherName"
|
||||
label="父亲姓名"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="newbornSex"
|
||||
label="新生儿性别"
|
||||
width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.auditStatus === 'shtg' ? 'success' : row.auditStatus === 'shth' ? 'danger' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ auditStatusLabel(String(row.auditStatus || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #default="{ row }">{{
|
||||
newbornSexLabel(String(row.newbornSex || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="auditRemark"
|
||||
label="审核备注"
|
||||
prop="deliveryMode"
|
||||
label="分娩方式"
|
||||
width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
deliveryModeLabel(String(row.deliveryMode || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="newbornWeightGram"
|
||||
label="新生儿体重(克)"
|
||||
width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="regionName"
|
||||
label="采集地区"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="hospitalName"
|
||||
label="采集医院"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="collectorUserName"
|
||||
label="采集专员"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="salesUserName"
|
||||
label="销售人员"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="sampleCollectTime"
|
||||
label="样本采集时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditTime"
|
||||
label="审核时间"
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
min-width="380"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
@@ -144,6 +252,12 @@
|
||||
<el-button class="btn-op-item" @click="openAuditDialog(row)"
|
||||
><el-icon><CircleCheck /></el-icon>审核</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -160,6 +274,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<DonationFormDialog
|
||||
v-model="formDialogVisible"
|
||||
:edit-id="formDialogEditId"
|
||||
:list-row="formDialogRow"
|
||||
@saved="onFormSaved"
|
||||
/>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<DonationAuditDialog
|
||||
v-model="auditDialogVisible"
|
||||
:targets="auditTargets"
|
||||
@submitted="onAuditSubmitted"
|
||||
/>
|
||||
|
||||
<!-- 状态变更弹窗(生效/过期/作废/转细胞存储) -->
|
||||
<DonationStatusChangeDialog
|
||||
v-model="statusChangeDialogVisible"
|
||||
:action-type="statusChangeAction"
|
||||
:targets="statusChangeTargets"
|
||||
@submitted="onStatusChangeSubmitted"
|
||||
/>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<DonationViewDialog
|
||||
v-model="viewVisible"
|
||||
@@ -167,14 +304,6 @@
|
||||
:list-row="viewRow"
|
||||
show-audit-history
|
||||
/>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<DonationAuditDialog
|
||||
v-model="auditDialogVisible"
|
||||
:donation-id="auditDonationId"
|
||||
:list-row="auditRow"
|
||||
@saved="onAuditSubmitted"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -182,55 +311,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
View,
|
||||
Edit,
|
||||
Delete,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
CircleCheck,
|
||||
CircleClose
|
||||
CircleCheck
|
||||
} from '@element-plus/icons-vue'
|
||||
import DonationFormDialog from '../list/DonationFormDialog.vue'
|
||||
import DonationViewDialog from '../list/DonationViewDialog.vue'
|
||||
import DonationAuditDialog from '../list/DonationAuditDialog.vue'
|
||||
import DonationAuditDialog, { type DonationAuditTarget } from '../list/DonationAuditDialog.vue'
|
||||
import DonationStatusChangeDialog, {
|
||||
type DonationStatusChangeAction,
|
||||
type DonationStatusChangeTarget
|
||||
} from '../list/DonationStatusChangeDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchResearchDonationAuditList,
|
||||
fetchSubmitDonationAudit,
|
||||
fetchDonationStatusChange,
|
||||
fetchResearchDonationList,
|
||||
fetchDeleteResearchDonation,
|
||||
type ResearchDonationRow
|
||||
} from '@/api/research-donation'
|
||||
|
||||
defineOptions({ name: 'ResearchDonationAudit' })
|
||||
|
||||
/* ====== 标签映射 ====== */
|
||||
function auditStatusLabel(s: string) {
|
||||
if (s === 'shtg') return '审核通过'
|
||||
if (s === 'shth') return '审核驳回'
|
||||
return s || '—'
|
||||
function donationStatusLabel(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
dsx: '待生效',
|
||||
sxz: '生效中',
|
||||
ygq: '已过期',
|
||||
yzf: '已作废',
|
||||
zckc: '转细胞存储'
|
||||
}
|
||||
return m[s] || s || '-'
|
||||
}
|
||||
|
||||
function newbornSexLabel(s: string) {
|
||||
if (s === '1') return '男'
|
||||
if (s === '0') return '女'
|
||||
return s || '-'
|
||||
}
|
||||
|
||||
function deliveryModeLabel(s: string) {
|
||||
if (s === 'sc') return '顺产'
|
||||
if (s === 'pgc') return '剖宫产'
|
||||
return s || '-'
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
type AuditRow = Record<string, unknown>
|
||||
|
||||
const searchForm = reactive<{ keyword: string; auditStatus: string }>({
|
||||
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
|
||||
keyword: '',
|
||||
auditStatus: ''
|
||||
donationStatus: ''
|
||||
})
|
||||
const tableData = ref<AuditRow[]>([])
|
||||
const selectedRows = ref<AuditRow[]>([])
|
||||
const tableData = ref<ResearchDonationRow[]>([])
|
||||
const selectedRows = ref<ResearchDonationRow[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const handleSelectionChange = (rows: AuditRow[]) => {
|
||||
const handleSelectionChange = (rows: ResearchDonationRow[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchResearchDonationAuditList({
|
||||
const res = await fetchResearchDonationList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
auditStatus: searchForm.auditStatus || undefined
|
||||
donationStatus: searchForm.donationStatus || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
@@ -246,7 +396,7 @@
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.auditStatus = ''
|
||||
searchForm.donationStatus = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
@@ -254,22 +404,49 @@
|
||||
/* ====== 查看弹窗 ====== */
|
||||
const viewVisible = ref(false)
|
||||
const viewDonationId = ref<string | null>(null)
|
||||
const viewRow = ref<AuditRow | null>(null)
|
||||
const viewRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleView(row: AuditRow) {
|
||||
function handleView(row: ResearchDonationRow) {
|
||||
viewDonationId.value = String(row.id)
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
/* ====== 新增/编辑弹窗 ====== */
|
||||
const formDialogVisible = ref(false)
|
||||
const formDialogEditId = ref<string | null>(null)
|
||||
const formDialogRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleAdd() {
|
||||
formDialogEditId.value = null
|
||||
formDialogRow.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: ResearchDonationRow) {
|
||||
formDialogEditId.value = row.id
|
||||
formDialogRow.value = row
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onFormSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 审核 ====== */
|
||||
const auditDialogVisible = ref(false)
|
||||
const auditDonationId = ref<string | null>(null)
|
||||
const auditRow = ref<AuditRow | null>(null)
|
||||
const auditTargets = ref<DonationAuditTarget[]>([])
|
||||
|
||||
function openAuditDialog(row: AuditRow) {
|
||||
auditDonationId.value = String(row.id)
|
||||
auditRow.value = row
|
||||
function openAuditDialog(row?: ResearchDonationRow) {
|
||||
const rows = row ? [row] : selectedRows.value
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('请先选择要审核的协议单')
|
||||
return
|
||||
}
|
||||
auditTargets.value = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
donationNo: r.donationNo != null ? String(r.donationNo) : undefined
|
||||
}))
|
||||
auditDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -278,59 +455,60 @@
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 批量审核 ====== */
|
||||
async function handleBatchAudit(status: string) {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning('请先选择要操作的记录')
|
||||
/* ====== 状态变更(生效/过期/作废/转细胞存储) ====== */
|
||||
const statusChangeDialogVisible = ref(false)
|
||||
const statusChangeAction = ref<DonationStatusChangeAction>('activate')
|
||||
const statusChangeTargets = ref<DonationStatusChangeTarget[]>([])
|
||||
|
||||
function openStatusChangeDialog(action: DonationStatusChangeAction, row?: ResearchDonationRow) {
|
||||
const rows = row ? [row] : selectedRows.value
|
||||
if (!rows.length) {
|
||||
ElMessage.warning(`请先选择要操作的协议单`)
|
||||
return
|
||||
}
|
||||
const label = status === 'shtg' ? '审核通过' : '审核驳回'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
|
||||
'批量审核',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const rows = [...selectedRows.value]
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const row of rows) {
|
||||
await fetchSubmitDonationAudit(String(row.id), { auditStatus: status })
|
||||
}
|
||||
ElMessage.success(`批量${label}成功`)
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}, fullscreenLoadingText.batchAudit)
|
||||
statusChangeAction.value = action
|
||||
statusChangeTargets.value = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
donationNo: r.donationNo != null ? String(r.donationNo) : undefined
|
||||
}))
|
||||
statusChangeDialogVisible.value = true
|
||||
}
|
||||
|
||||
/* ====== 批量状态变更 ====== */
|
||||
async function handleBatchStatusChange(action: 'activate' | 'void') {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning('请先选择要操作的记录')
|
||||
return
|
||||
}
|
||||
const label = action === 'activate' ? '生效' : '作废'
|
||||
const targetStatus = action === 'activate' ? 'sxz' : 'yzf'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
|
||||
`批量${label}`,
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
function onStatusChangeSubmitted() {
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 删除 ====== */
|
||||
function handleDelete(row: ResearchDonationRow) {
|
||||
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async () => {
|
||||
await fetchDeleteResearchDonation(String(row.id))
|
||||
loadData()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRows.value.length) return
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录?`, '批量删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
const rows = [...selectedRows.value]
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const row of rows) {
|
||||
await fetchDonationStatusChange(String(row.id), { donationStatus: targetStatus })
|
||||
await fetchDeleteResearchDonation(String(row.id))
|
||||
}
|
||||
ElMessage.success(`批量${label}成功`)
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}, fullscreenLoadingText.save)
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 捐赠协议单 — 审核弹窗 -->
|
||||
<!-- 捐赠协议单 — 提交审核(支持批量,写入 biz_donation_audit) -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
@@ -8,12 +8,15 @@
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<p class="audit-target-hint">
|
||||
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
|
||||
<p v-if="targets.length > 1" class="audit-target-hint">
|
||||
将对已选
|
||||
<strong>{{ targets.length }}</strong> 条捐赠协议单提交相同审核结果,每条各记一条审核记录。
|
||||
</p>
|
||||
<p v-else-if="targets.length === 1" class="audit-target-hint">
|
||||
捐赠编号:<strong>{{ targets[0].donationNo || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
@@ -27,7 +30,7 @@
|
||||
<el-form-item label="审核状态" prop="auditStatus">
|
||||
<el-radio-group v-model="form.auditStatus">
|
||||
<el-radio value="shtg">审核通过</el-radio>
|
||||
<el-radio value="shth">审核驳回</el-radio>
|
||||
<el-radio value="shth">审核退回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注">
|
||||
@@ -57,21 +60,26 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchSubmitDonationAudit } from '@/api/research-donation'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
export type DonationAuditTarget = {
|
||||
id: string
|
||||
donationNo?: string
|
||||
}
|
||||
|
||||
defineOptions({ name: 'DonationAuditDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
donationId: string | null
|
||||
listRow?: Record<string, unknown> | null
|
||||
targets: DonationAuditTarget[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
@@ -85,7 +93,9 @@
|
||||
auditStatus: [{ required: true, message: '请选择审核状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => '捐赠协议单审核')
|
||||
const dialogTitle = computed(() =>
|
||||
props.targets.length > 1 ? `批量审核(${props.targets.length} 条)` : '捐赠协议单审核'
|
||||
)
|
||||
|
||||
function resetForm() {
|
||||
form.auditStatus = 'shtg'
|
||||
@@ -95,22 +105,44 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.donationId) return
|
||||
if (!valid || !props.targets.length) return
|
||||
|
||||
const payload = {
|
||||
auditStatus: form.auditStatus,
|
||||
auditRemark: form.auditRemark.trim() || undefined
|
||||
}
|
||||
const rows = [...props.targets]
|
||||
const isBatch = rows.length > 1
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
await fetchSubmitDonationAudit(props.donationId!, payload)
|
||||
emit('update:modelValue', false)
|
||||
emit('saved')
|
||||
let ok = 0
|
||||
const failed: string[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await fetchSubmitDonationAudit(String(row.id), payload, {
|
||||
showSuccessMessage: !isBatch
|
||||
})
|
||||
ok += 1
|
||||
} catch {
|
||||
failed.push(row.donationNo || String(row.id))
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`成功 ${ok} 条,失败 ${failed.length} 条`)
|
||||
} else {
|
||||
ElMessage.success(`批量审核成功,共 ${ok} 条`)
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
}
|
||||
},
|
||||
fullscreenLoadingText.save
|
||||
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,12 +8,16 @@
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<p class="status-change-hint">
|
||||
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
|
||||
<p v-if="targets.length > 1" class="status-change-hint">
|
||||
将对已选
|
||||
<strong>{{ targets.length }}</strong> 条捐赠协议单执行<strong>{{ actionLabel }}</strong
|
||||
>操作,每条各记一次变更。
|
||||
</p>
|
||||
<p v-else-if="targets.length === 1" class="status-change-hint">
|
||||
捐赠编号:<strong>{{ targets[0].donationNo || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
@@ -24,27 +28,6 @@
|
||||
class="biz-dialog-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="变更操作" prop="operationType">
|
||||
<el-radio-group v-model="form.operationType" @change="handleOperationChange">
|
||||
<el-radio
|
||||
v-for="op in availableOperations"
|
||||
:key="op.value"
|
||||
:value="op.value"
|
||||
>
|
||||
{{ op.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="form.operationType === 'zckc'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="convert-alert"
|
||||
>
|
||||
转为细胞存储后将创建新的细胞存储协议,需补填缴费信息。
|
||||
</el-alert>
|
||||
|
||||
<el-form-item :label="timeLabel" prop="changeTime">
|
||||
<el-date-picker
|
||||
v-model="form.changeTime"
|
||||
@@ -54,7 +37,7 @@
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-form-item :label="descLabel">
|
||||
<el-input
|
||||
v-model="form.changeDesc"
|
||||
type="textarea"
|
||||
@@ -81,102 +64,97 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchDonationStatusChange } from '@/api/research-donation'
|
||||
import { fetchDonationStatusChange, fetchConvertToStorage } from '@/api/research-donation'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
type OperationType = 'activate' | 'expire' | 'void' | 'zckc'
|
||||
export type DonationStatusChangeAction = 'activate' | 'expire' | 'void' | 'toStorage'
|
||||
export type DonationStatusChangeTarget = {
|
||||
id: string
|
||||
donationNo?: string
|
||||
}
|
||||
|
||||
defineOptions({ name: 'DonationStatusChangeDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
donationId: string | null
|
||||
listRow?: Record<string, unknown> | null
|
||||
actionType: DonationStatusChangeAction
|
||||
targets: DonationStatusChangeTarget[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const OPERATION_CONFIG: Record<
|
||||
OperationType,
|
||||
const ACTION_CONFIG: Record<
|
||||
DonationStatusChangeAction,
|
||||
{
|
||||
label: string
|
||||
timeLabel: string
|
||||
targetStatus: string
|
||||
descLabel: string
|
||||
status: string
|
||||
timeKey: string
|
||||
descKey: string
|
||||
}
|
||||
> = {
|
||||
activate: {
|
||||
label: '生效',
|
||||
timeLabel: '生效时间',
|
||||
targetStatus: 'sxz'
|
||||
label: '协议生效',
|
||||
timeLabel: '协议生效时间',
|
||||
descLabel: '协议生效描述',
|
||||
status: 'sxz',
|
||||
timeKey: 'effectiveTime',
|
||||
descKey: 'effectiveDesc'
|
||||
},
|
||||
expire: {
|
||||
label: '过期',
|
||||
timeLabel: '过期时间',
|
||||
targetStatus: 'ygq'
|
||||
label: '协议过期',
|
||||
timeLabel: '协议过期时间',
|
||||
descLabel: '协议过期描述',
|
||||
status: 'ygq',
|
||||
timeKey: 'expireTime',
|
||||
descKey: 'expireDesc'
|
||||
},
|
||||
void: {
|
||||
label: '作废',
|
||||
timeLabel: '作废时间',
|
||||
targetStatus: 'yzf'
|
||||
label: '协议作废',
|
||||
timeLabel: '协议作废时间',
|
||||
descLabel: '协议作废描述',
|
||||
status: 'yzf',
|
||||
timeKey: 'voidTime',
|
||||
descKey: 'voidDesc'
|
||||
},
|
||||
zckc: {
|
||||
toStorage: {
|
||||
label: '转细胞存储',
|
||||
timeLabel: '转换时间',
|
||||
targetStatus: 'zckc'
|
||||
timeLabel: '转细胞存储时间',
|
||||
descLabel: '转细胞存储描述',
|
||||
status: 'zckc',
|
||||
timeKey: 'convertTime',
|
||||
descKey: 'convertDesc'
|
||||
}
|
||||
}
|
||||
|
||||
const currentStatus = computed(() => String(props.listRow?.donationStatus || ''))
|
||||
const config = computed(() => ACTION_CONFIG[props.actionType])
|
||||
const actionLabel = computed(() => config.value.label)
|
||||
const timeLabel = computed(() => config.value.timeLabel)
|
||||
const descLabel = computed(() => config.value.descLabel)
|
||||
|
||||
const availableOperations = computed(() => {
|
||||
const ops: Array<{ value: OperationType; label: string }> = []
|
||||
const s = currentStatus.value
|
||||
|
||||
if (s === 'dsx') {
|
||||
ops.push({ value: 'activate', label: '生效' })
|
||||
ops.push({ value: 'void', label: '作废' })
|
||||
} else if (s === 'sxz') {
|
||||
ops.push({ value: 'expire', label: '过期' })
|
||||
ops.push({ value: 'void', label: '作废' })
|
||||
ops.push({ value: 'zckc', label: '转细胞存储' })
|
||||
}
|
||||
|
||||
return ops
|
||||
const dialogTitle = computed(() => {
|
||||
const label = config.value.label
|
||||
return props.targets.length > 1 ? `批量${label}(${props.targets.length} 条)` : label
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
operationType: '' as OperationType | '',
|
||||
changeTime: '',
|
||||
changeDesc: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
operationType: [{ required: true, message: '请选择变更操作', trigger: 'change' }],
|
||||
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
if (!form.operationType) return null
|
||||
return OPERATION_CONFIG[form.operationType]
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => config.value?.timeLabel || '时间')
|
||||
|
||||
const dialogTitle = computed(() => '捐赠协议单 — 状态变更')
|
||||
|
||||
function handleOperationChange() {
|
||||
form.changeTime = ''
|
||||
form.changeDesc = ''
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.operationType = ''
|
||||
form.changeTime = ''
|
||||
form.changeDesc = ''
|
||||
formRef.value?.clearValidate()
|
||||
@@ -184,12 +162,60 @@
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.donationId || !form.operationType) return
|
||||
if (!valid || !props.targets.length) return
|
||||
|
||||
const cfg = config.value
|
||||
const rows = [...props.targets]
|
||||
const isBatch = rows.length > 1
|
||||
|
||||
// 转细胞存储:确认 + 调用专用接口
|
||||
if (props.actionType === 'toStorage') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定将该捐赠协议转为细胞存储吗?将创建新的细胞存储协议,初始协议号与样本编码与源单一致。',
|
||||
'转细胞存储确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
let ok = 0
|
||||
const failed: string[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await fetchConvertToStorage(String(row.id))
|
||||
ok += 1
|
||||
} catch {
|
||||
failed.push(row.donationNo || String(row.id))
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
|
||||
} else {
|
||||
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
}
|
||||
},
|
||||
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cfg = OPERATION_CONFIG[form.operationType]
|
||||
const payload = {
|
||||
donationStatus: cfg.targetStatus,
|
||||
time: form.changeTime,
|
||||
newStatus: cfg.status,
|
||||
timeVal: form.changeTime,
|
||||
desc: form.changeDesc.trim() || undefined
|
||||
}
|
||||
|
||||
@@ -197,11 +223,29 @@
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
await fetchDonationStatusChange(props.donationId!, payload)
|
||||
emit('update:modelValue', false)
|
||||
emit('saved')
|
||||
let ok = 0
|
||||
const failed: string[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await fetchDonationStatusChange(String(row.id), payload)
|
||||
ok += 1
|
||||
} catch {
|
||||
failed.push(row.donationNo || String(row.id))
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
|
||||
} else {
|
||||
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
}
|
||||
},
|
||||
fullscreenLoadingText.save
|
||||
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -221,8 +265,4 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.convert-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,7 @@
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-form-item label="协议状态">
|
||||
<el-select
|
||||
v-model="searchForm.donationStatus"
|
||||
clearable
|
||||
@@ -89,6 +89,17 @@
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationStatus"
|
||||
label="状态"
|
||||
width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
donationStatusLabel(String(row.donationStatus || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="donationDate"
|
||||
label="捐赠日期"
|
||||
@@ -104,29 +115,90 @@
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationStatus"
|
||||
label="状态"
|
||||
width="120"
|
||||
prop="motherPhone"
|
||||
label="母亲电话"
|
||||
min-width="130"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(String(row.donationStatus || ''))" disable-transitions>
|
||||
{{ donationStatusLabel(String(row.donationStatus || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
/>
|
||||
<el-table-column
|
||||
prop="sourceType"
|
||||
label="来源类型"
|
||||
width="120"
|
||||
prop="motherIdCard"
|
||||
label="母亲身份证号"
|
||||
min-width="190"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="fatherName"
|
||||
label="父亲姓名"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="newbornSex"
|
||||
label="新生儿性别"
|
||||
width="110"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
sourceTypeLabel(String(row.sourceType || ''))
|
||||
newbornSexLabel(String(row.newbornSex || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="deliveryMode"
|
||||
label="分娩方式"
|
||||
width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
deliveryModeLabel(String(row.deliveryMode || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="newbornWeightGram"
|
||||
label="新生儿体重(克)"
|
||||
width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="regionName"
|
||||
label="采集地区"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="hospitalName"
|
||||
label="采集医院"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="collectorUserName"
|
||||
label="采集专员"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="salesUserName"
|
||||
label="销售人员"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="sampleCollectTime"
|
||||
label="样本采集时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
@@ -148,12 +220,6 @@
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleAudit(row)"
|
||||
><el-icon><CircleCheck /></el-icon>审核</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleStatusChange(row)"
|
||||
><el-icon><Switch /></el-icon>状态变更</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
@@ -188,42 +254,15 @@
|
||||
:list-row="viewRow"
|
||||
show-audit-history
|
||||
/>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<DonationAuditDialog
|
||||
v-model="auditVisible"
|
||||
:donation-id="auditDonationId"
|
||||
:list-row="auditRow"
|
||||
@saved="onAuditSaved"
|
||||
/>
|
||||
|
||||
<!-- 状态变更弹窗 -->
|
||||
<DonationStatusChangeDialog
|
||||
v-model="statusChangeVisible"
|
||||
:donation-id="statusChangeDonationId"
|
||||
:list-row="statusChangeRow"
|
||||
@saved="onStatusChangeSaved"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
View,
|
||||
Edit,
|
||||
Delete,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
CircleCheck,
|
||||
Switch
|
||||
} from '@element-plus/icons-vue'
|
||||
import { Plus, View, Edit, Delete, Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import DonationFormDialog from './DonationFormDialog.vue'
|
||||
import DonationViewDialog from './DonationViewDialog.vue'
|
||||
import DonationAuditDialog from './DonationAuditDialog.vue'
|
||||
import DonationStatusChangeDialog from './DonationStatusChangeDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchResearchDonationList,
|
||||
@@ -245,20 +284,15 @@
|
||||
return m[s] || s || '-'
|
||||
}
|
||||
|
||||
function statusTagType(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'info',
|
||||
sxz: 'success',
|
||||
ygq: 'warning',
|
||||
yzf: 'danger',
|
||||
zckc: 'primary'
|
||||
}
|
||||
return m[s] || 'info'
|
||||
function newbornSexLabel(s: string) {
|
||||
if (s === '1') return '男'
|
||||
if (s === '0') return '女'
|
||||
return s || '-'
|
||||
}
|
||||
|
||||
function sourceTypeLabel(s: string) {
|
||||
if (s === 'direct') return '独立新建'
|
||||
if (s === 'from_storage') return '从存储转来'
|
||||
function deliveryModeLabel(s: string) {
|
||||
if (s === 'sc') return '顺产'
|
||||
if (s === 'pgc') return '剖宫产'
|
||||
return s || '-'
|
||||
}
|
||||
|
||||
@@ -327,7 +361,7 @@
|
||||
}
|
||||
|
||||
function handleEdit(row: ResearchDonationRow) {
|
||||
formDialogEditId.value = row.id as string
|
||||
formDialogEditId.value = row.id
|
||||
formDialogRow.value = row
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
@@ -336,36 +370,6 @@
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 审核弹窗 ====== */
|
||||
const auditVisible = ref(false)
|
||||
const auditDonationId = ref<string | null>(null)
|
||||
const auditRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleAudit(row: ResearchDonationRow) {
|
||||
auditDonationId.value = String(row.id)
|
||||
auditRow.value = row
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
function onAuditSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 状态变更弹窗 ====== */
|
||||
const statusChangeVisible = ref(false)
|
||||
const statusChangeDonationId = ref<string | null>(null)
|
||||
const statusChangeRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleStatusChange(row: ResearchDonationRow) {
|
||||
statusChangeDonationId.value = String(row.id)
|
||||
statusChangeRow.value = row
|
||||
statusChangeVisible.value = true
|
||||
}
|
||||
|
||||
function onStatusChangeSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 删除 ====== */
|
||||
function handleDelete(row: ResearchDonationRow) {
|
||||
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {
|
||||
|
||||
Reference in new issue
Block a user