This commit is contained in:
zlwu committed 2026-05-19 12:03:08 +08:00
1 parent f269a994f4
commit 93cd506a90
71 files changed
+8297 -2207

No files matched your search

+1 -1
View File
@@ -1 +1 @@
38796
11478
+31
View File
@@ -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
View File
@@ -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",
+8 -2
View File
@@ -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))
+35 -6
View File
@@ -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"),
+61 -36
View File
@@ -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))
+42 -1
View File
@@ -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:
"""获取存储实例"""
+80 -34
View File
@@ -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",
+6
View File
@@ -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:
+6 -3
View File
@@ -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,
+91 -2
View File
@@ -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:
+55 -1
View File
@@ -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,
)
+11 -5
View File
@@ -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
+225 -33
View File
@@ -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 -2
View File
@@ -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]:
+3
View File
@@ -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",
]
+21
View File
@@ -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
+21
View File
@@ -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='细胞存储协议与科研捐赠转移关联';
+8
View File
@@ -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
View File
@@ -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`;
+10
View File
@@ -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;
+2 -2
View File
@@ -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);
+155
View File
@@ -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
View File
@@ -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` |
---
*文档结束*
+10
View File
@@ -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"
]
}
+7
View File
@@ -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',
+1
View File
@@ -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
+1 -1
View File
@@ -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>
+24 -31
View File
@@ -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' }
}
]
},
+2 -2
View File
@@ -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)
}
+4 -1
View File
@@ -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() {
+485 -112
View File
@@ -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}」?`, '删除确认', {