Compare commits

...
Author SHA1 Message Date
zlwuandCursor 2124ce9208 fix(lab-sample): 剔除导出自存伪数据并补齐间充质采集字段
过滤导出自存占位协议与无接收间充质行;科研捐献行从捐赠采集表取采集时间,运输温度回落到接收温度。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:38:33 +08:00
zlwuandCursor e7b3876db6 fix(lab-sample): 恢复 Excel 真菌等补充字段并同步质检
纠正入库编码被误软删问题,恢复可关联样本的补充字段;列表接入质检填空,并补充间充质科研捐献接收行。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:32:09 +08:00
zlwuandCursor c0aba421da fix(lab-sample): 新旧实验室样本台账补录双向联动
新模块读取旧台账补录并映射字段;旧台账保存时同步到新补充表与生产传染四项,新模块保存时回写旧补录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:19:19 +08:00
zlwuandCursor 2aecb69cc4 fix(lab-sample): 细胞编码、母血等字段与传染四项显示
列表细胞编码改展示实验室入库编码;从母血采集时间、协议附件与收样检查推导是否有母血等列;传染四项 zc/bzc 显示为正常/不正常。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 17:01:05 +08:00
zlwuandCursor 2878c6889d fix(lab-sample): 归一化市场生产质检字段 ACL 授权
撤销历史过宽角色菜单后,仅按部门员工/主管/只读角色重授,确保员工仅可编辑本部门字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:54:59 +08:00
zlwuandCursor 4aee2b1fe5 fix(web-admin): 恢复实验室样本处理三表前端路由
合入 #100 后静态路由缺失导致刷新后顶级菜单与页面不可达,补回 hsc/ubmc/msc 路由注册。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:47:03 +08:00
zlwuandCursor 5698a9dfc4 fix(lab-sample): 台账改聚合真实市场生产质检数据
去掉 Excel 演示独有行,源表字段优先于补充表;按协议样本与科研捐献接收关联已发生业务数据,并修复导出分页超限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:41:26 +08:00
zlwuandCursor 77d45d0bc5 feat(menu): 实验室样本处理数据挂到市场/生产/质检并分端授权
continuous-integration/drone/tag Build is passing
质检入口调到入库放行审核下方;存储服务挂缴费财务确认下方;生产管理挂废弃物管理下方。三处权限码分离,拥有对应顶级菜单的角色授予本端入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:18:27 +08:00
cykj dc0f8541c4 merge: 合入 #100 移除多余一级菜单实验室样本处理数据
merge: 合入 #100 移除多余一级菜单实验室样本处理数据
2026-11-12 06:02:59 +00:00
zlwuandCursor 4fd6ef4be5 fix(web-admin): 移除多余一级菜单实验室样本处理数据
软删除 menu:lab-sample-processing 及子菜单/按钮与角色授权,并去掉静态路由;保留质检下 lab-sample-process 台账。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:01:46 +08:00
zlwuandCursor b3c191ee99 fix(web-admin): 实验室样本处理数据 Tab 对齐样本接收胶囊样式
与质检样本接收页共用 panel-pill-tabs,保持质检管理列表页视觉一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 13:33:44 +08:00
zlwu bbef46b645 merge: 合入 #99 实验室样本处理三表台账与部门字段 ACL 2026-11-12 12:50:29 +08:00
zlwu 5fe1193a2c feat(lab-sample): 实验室样本处理三表台账与部门字段 ACL
按造血/单个核/间充质聚合展示,部门员工只改本部门、主管可改全部,并支持筛选与 Excel 导出;同步纠正采集接受人串权与严格菜单守卫。
2026-11-12 12:48:05 +08:00
zlwu 58b252ce6b merge: 合入 #98 实验室样本处理数据台账 2026-11-12 12:12:55 +08:00
zlwuandCursor acb81cadd5 feat(qc): 新增实验室样本处理数据台账(4 Tab + 补录 + 导出)
在质检管理下增加按来源×样本类型展示的台账页,聚合接收/生产/质检参数,
补录按实验室入库编码关联,支持 Excel 导出与中文时间格式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 12:11:57 +08:00
zlwuandCursor a64dd563bd fix(sql): 储存证 ACL 配置按钮授权给全部角色
continuous-integration/drone/tag Build is passing
迁移脚本为各租户有效角色补齐 storage-cert-qc-acl 菜单,使质检/生产权限入口对全部角色可见。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:03:49 +08:00
zlwuandCursor 377e0981ab fix(web-admin): 储存证 ACL 配置人员支持姓名模糊检索
远程下拉改为用检索结果替换选项并保留已选,修复只追加导致输入姓名后列表不收缩的问题。质检/生产权限共用同一弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:01:28 +08:00
zlwuandCursor 4654678231 fix(cell-storage): 储存证 ACL 保存避免 collation 混用 503
ACL 表与 sys_user JOIN/条件比较统一 utf8mb4_unicode_ci,并补建表 COLLATE 与 CONVERT 修复脚本(#97)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:56:58 +08:00
zlwu a6d3f6f610 merge: #96 储存证 ACL 字段勾选全选/取消全选 2026-11-11 16:42:25 +08:00
zlwu 6cd5ced688 feat(web-admin): 储存证 ACL 字段勾选支持全选/取消全选
质检/生产权限弹窗「勾选可编辑字段」Tab 增加一键全选与取消全选。关联 #96。
2026-11-11 16:42:10 +08:00
zlwuandCursor 843f44929c fix(web-admin): 储存证 ACL 字段勾选改为两列布局
质检/生产权限弹窗「勾选可编辑字段」Tab 使用两列网格,窄屏回退单列。关联 #95。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:37:30 +08:00
zlwu 87a22880e8 merge: #93 储存证质检字段级质检/生产权限 2026-11-11 16:15:07 +08:00
zlwuandCursor 529f36eac6 feat(cell-storage): 储存证质检字段级质检/生产权限
按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:13:20 +08:00
zlwu 9dc6812393 merge: #94 客户服务管理列表性别显示修复 2026-11-11 15:49:36 +08:00
zlwuandCursor e482980c7d fix(web-admin): 客户服务管理列表性别按新生儿约定显示
本地 sexLabel 误将女映射为 2,导致 0 原样展示;改为复用 newbornSexLabel(1男/0女)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:48:37 +08:00
zlwu 900e957f25 merge: #91 脐带间充质储存证隐藏母血三项核酸 2026-11-11 15:15:43 +08:00
zlwuandCursor 15a20db725 fix(cell-storage): 脐带间充质储存证隐藏母血三项核酸
脐带间充质干细胞填写/查看储存证质检时不展示、不预填、不落库
HBV DNA / HCV RNA / HIV-1RNA;围产期造血干细胞保持原字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:15:01 +08:00
zlwuandCursor 3ac30a6c2c fix(web-admin): 恢复采集大表导出并按运费批次归拢合并
去掉临时隐藏开关,导出自存/科研表时先按运费批次聚拢再合并单元格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:56:45 +08:00
zlwuandCursor 95d6b6025f fix(web-admin): 样本接收列表卡片协议附件与弹窗对齐
卡片改用完整附件分组,展示知情同意书/来源主附件及协议附件或其他附件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:54:16 +08:00
cykj d0eb97b167 fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)
fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)

Merged from PR #89.
2026-11-11 06:17:00 +00:00
61 changed files with 7298 additions and 96 deletions

No files matched your search

+4
View File
@@ -45,11 +45,14 @@ from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_rout
from app.api.customer_service_dict import router as customer_service_dict_router from app.api.customer_service_dict import router as customer_service_dict_router
from app.api.sample_cert_insurance import router as sample_cert_insurance_router from app.api.sample_cert_insurance import router as sample_cert_insurance_router
from app.api.sample_code_correction import router as sample_code_correction_router from app.api.sample_code_correction import router as sample_code_correction_router
from app.api.lab_sample_process import router as lab_sample_process_router
from app.api.lab_sample_processing import router as lab_sample_processing_router
from app.api.project_management import router as project_management_router from app.api.project_management import router as project_management_router
__all__ = [ __all__ = [
"project_management_router", "project_management_router",
"lab_sample_process_router",
"public_router", "public_router",
"menu_router", "menu_router",
"admin_router", "admin_router",
@@ -96,4 +99,5 @@ __all__ = [
"customer_service_dict_router", "customer_service_dict_router",
"sample_cert_insurance_router", "sample_cert_insurance_router",
"sample_code_correction_router", "sample_code_correction_router",
"lab_sample_processing_router",
] ]
+147
View File
@@ -0,0 +1,147 @@
"""实验室样本处理数据台账 API"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_any_menu_code
from app.core.response import error_response, success_response
from app.crud import lab_sample_process as svc
from app.models.models import SysUser
from app.services import lab_sample_process as ledger
router = APIRouter(prefix="/web/admin", tags=["质检管理"])
# 市场/生产/质检三处菜单权限码不同,接口按并集鉴权
LIST_MENU_CODES = (
"biz:labSampleProcess:list",
"production:labSampleProcess:list",
"cellStorage:labSampleProcess:list",
)
SUPPLEMENT_MENU_CODES = (
"biz:labSampleProcess:supplement",
"production:labSampleProcess:supplement",
"cellStorage:labSampleProcess:supplement",
)
def _tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失,无法确定租户")
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
if not sys_user or not sys_user.tenant_no:
raise PermissionError("用户租户信息缺失")
return sys_user.tenant_no
def _user_id(user: dict) -> str:
uid = user.get("user_id")
if not uid:
raise PermissionError("用户身份信息缺失")
return str(uid)
def _dept_id(user: dict) -> str:
return str(user.get("dept_id") or user.get("create_dept_id") or "")
class SupplementBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
lab_storage_code: str = Field(..., alias="labStorageCode")
reception_source: str = Field(..., alias="receptionSource")
sample_type_name: str = Field(..., alias="sampleTypeName")
payload: Dict[str, Any] = Field(default_factory=dict)
remark: Optional[str] = None
@router.get("/lab-sample-process/list")
def list_lab_sample_process(
tabKey: str = Query(..., description="cell_hsc|donation_hsc|cell_msc|donation_msc"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=200),
labStorageCode: Optional[str] = Query(None),
motherName: Optional[str] = Query(None),
createTimeStart: Optional[str] = Query(None),
createTimeEnd: Optional[str] = Query(None),
db: Session = Depends(get_db),
user=Depends(require_any_menu_code(*LIST_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
items, total = svc.list_ledger(
db,
tab_key=tabKey,
page=page,
page_size=pageSize,
tenant_no=tenant_no,
lab_storage_code=labStorageCode,
mother_name=motherName,
create_time_start=createTimeStart,
create_time_end=createTimeEnd,
user_info=user,
)
return success_response(
data={
"list": items,
"total": total,
"page": page,
"pageSize": pageSize,
"tabKey": tabKey,
"tabMeta": ledger.TAB_META.get(tabKey),
"supplementKeys": ledger.SUPPLEMENT_KEYS.get(tabKey, []),
}
)
except PermissionError as e:
return error_response(str(e), code=403)
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(f"查询失败:{e}")
@router.get("/lab-sample-process/supplement")
def get_supplement(
labStorageCode: str = Query(...),
db: Session = Depends(get_db),
user=Depends(require_any_menu_code(*LIST_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
data = svc.get_supplement(db, tenant_no=tenant_no, lab_storage_code=labStorageCode)
return success_response(data=data or {"labStorageCode": labStorageCode, "payload": {}})
except PermissionError as e:
return error_response(str(e), code=403)
except Exception as e:
return error_response(f"查询补录失败:{e}")
@router.put("/lab-sample-process/supplement")
def save_supplement(
body: SupplementBody,
db: Session = Depends(get_db),
user=Depends(require_any_menu_code(*SUPPLEMENT_MENU_CODES)),
):
try:
tenant_no = _tenant_no(db, user)
data = svc.upsert_supplement(
db,
tenant_no=tenant_no,
user_id=_user_id(user),
dept_id=_dept_id(user),
lab_storage_code=body.lab_storage_code,
reception_source=body.reception_source,
sample_type_name=body.sample_type_name,
payload=body.payload or {},
remark=body.remark,
)
return success_response(data=data, message="补录已保存")
except PermissionError as e:
return error_response(str(e), code=403)
except ValueError as e:
return error_response(str(e))
except Exception as e:
return error_response(f"保存补录失败:{e}")
+193
View File
@@ -0,0 +1,193 @@
"""实验室样本处理数据 API"""
from __future__ import annotations
import csv
import io
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_strict_any_menu_code
from app.core.response import error_response, success_response
from app.models.models import SysUser
from app.services import lab_sample_processing as svc
from app.services.lab_sample_processing_fields import (
DATASETS,
DATASET_LABELS,
PERM_LIST,
PERM_MANAGER,
PERM_MARKET_EDIT,
PERM_PRODUCTION_EDIT,
PERM_QC_EDIT,
schema_payload,
)
router = APIRouter(prefix="/web/admin/lab-sample-processing", tags=["实验室样本处理数据"])
# 列表/导出:有列表、主管或任一部门编辑权即可查看全部字段
READ_MENUS = (
PERM_LIST,
PERM_MANAGER,
PERM_MARKET_EDIT,
PERM_PRODUCTION_EDIT,
PERM_QC_EDIT,
)
# 更新:至少要有主管或某一部门编辑权(字段级 ACL 在服务内再收紧)
WRITE_MENUS = (
PERM_MANAGER,
PERM_MARKET_EDIT,
PERM_PRODUCTION_EDIT,
PERM_QC_EDIT,
)
def _tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
row = db.query(SysUser).filter(SysUser.id == user_id, SysUser.del_flag == 0).first()
if not row or not row.tenant_no:
raise PermissionError("用户租户信息缺失")
return row.tenant_no
def _ensure_dataset(dataset: str) -> Optional[str]:
ds = (dataset or "").strip().lower()
return ds if ds in DATASETS else None
class UpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
payload: Dict[str, Any] = Field(default_factory=dict)
@router.get("/meta/{dataset}")
async def get_meta(
dataset: str,
user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)),
db: Session = Depends(get_db),
):
ds = _ensure_dataset(dataset)
if not ds:
return error_response(400, "未知数据集")
acl = svc.user_acl(db, user)
return success_response(
{
"dataset": ds,
"label": DATASET_LABELS.get(ds),
"fields": schema_payload(ds),
"acl": acl,
}
)
@router.get("/{dataset}/list")
async def list_dataset(
dataset: str,
keyword: Optional[str] = Query(None),
dateFrom: Optional[str] = Query(None),
dateTo: Optional[str] = Query(None),
page: int = Query(1, ge=1),
pageSize: int = Query(20, ge=1, le=200),
user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)),
db: Session = Depends(get_db),
):
ds = _ensure_dataset(dataset)
if not ds:
return error_response(400, "未知数据集")
tenant_no = _tenant_no(db, user)
rows, total = svc.list_rows(
db,
tenant_no=tenant_no,
dataset=ds,
keyword=keyword,
date_from=dateFrom,
date_to=dateTo,
page=page,
page_size=pageSize,
)
acl = svc.user_acl(db, user)
return success_response(
{
"list": rows,
"total": total,
"page": page,
"pageSize": pageSize,
"fields": schema_payload(ds),
"acl": acl,
}
)
@router.put("/{dataset}/{sample_code}")
async def update_dataset_row(
dataset: str,
sample_code: str,
body: UpdateBody,
user: dict = Depends(require_strict_any_menu_code(*WRITE_MENUS)),
db: Session = Depends(get_db),
):
ds = _ensure_dataset(dataset)
if not ds:
return error_response(400, "未知数据集")
tenant_no = _tenant_no(db, user)
acl = svc.user_acl(db, user)
editable = set(acl.get("editableDepts") or [])
if not editable:
return error_response(403, "无编辑权限")
try:
row = svc.update_row(
db,
tenant_no=tenant_no,
dataset=ds,
sample_code=sample_code,
payload=body.payload or {},
editable_depts=editable,
operator_id=str(user.get("user_id") or ""),
)
except PermissionError as exc:
return error_response(403, str(exc))
except LookupError as exc:
return error_response(404, str(exc))
except ValueError as exc:
return error_response(400, str(exc))
return success_response(row)
@router.get("/{dataset}/export")
async def export_dataset(
dataset: str,
keyword: Optional[str] = Query(None),
dateFrom: Optional[str] = Query(None),
dateTo: Optional[str] = Query(None),
user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)),
db: Session = Depends(get_db),
):
ds = _ensure_dataset(dataset)
if not ds:
return error_response(400, "未知数据集")
tenant_no = _tenant_no(db, user)
headers, body = svc.export_rows(
db,
tenant_no=tenant_no,
dataset=ds,
keyword=keyword,
date_from=dateFrom,
date_to=dateTo,
)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(headers)
for row in body:
writer.writerow(["" if v is None else v for v in row])
raw = ("\ufeff" + buf.getvalue()).encode("utf-8")
filename = f"lab-sample-{ds}.csv"
return StreamingResponse(
io.BytesIO(raw),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+95 -3
View File
@@ -1,19 +1,28 @@
"""样本储存证质检信息 API""" """样本储存证质检信息 API"""
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.menu_guard import require_any_menu_code, require_strict_any_menu_code from app.core.menu_guard import (
require_any_menu_code,
require_strict_any_menu_code,
require_strict_menu_code,
strict_user_has_any_menu_perm,
strict_user_has_menu_perm,
user_has_menu_perm,
)
from app.core.response import error_response, success_response from app.core.response import error_response, success_response
from app.crud import sample_storage_cert_qc as svc from app.crud import sample_storage_cert_qc as svc
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.models.models import SysUser from app.models.models import SysUser
from app.services.sample_storage_cert_qc import ACL_CONFIG_MENU
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"]) router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖) # 市场 / 质检 / 生产三端共用读写
READ_MENUS = ( READ_MENUS = (
"biz:qcWorkOrder:list", "biz:qcWorkOrder:list",
"biz:myQcTask:list", "biz:myQcTask:list",
@@ -41,6 +50,12 @@ def _get_current_user_id(user: dict) -> str:
return str(user_id) return str(user_id)
def _can_configure_acl(db: Session, user: dict) -> bool:
return user_has_menu_perm(db, user, ACL_CONFIG_MENU) or strict_user_has_menu_perm(
db, user, ACL_CONFIG_MENU
)
class UpsertBody(BaseModel): class UpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
@@ -48,6 +63,13 @@ class UpsertBody(BaseModel):
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId") qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
class AclUpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
field_keys: List[str] = Field(default_factory=list, alias="fieldKeys")
user_ids: List[str] = Field(default_factory=list, alias="userIds")
@router.get("/sample-storage-cert-qc/{sample_code}") @router.get("/sample-storage-cert-qc/{sample_code}")
async def get_sample_storage_cert_qc( async def get_sample_storage_cert_qc(
sample_code: str, sample_code: str,
@@ -55,9 +77,19 @@ async def get_sample_storage_cert_qc(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
tenant_no = _get_tenant_no(db, user) tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
detail = svc.get_detail(db, tenant_no, sample_code) detail = svc.get_detail(db, tenant_no, sample_code)
if detail is None: if detail is None:
return error_response(404, "样本不存在") return error_response(404, "样本不存在")
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
svc.attach_acl_context(
db,
detail,
tenant_no=tenant_no,
user_id=user_id,
has_legacy_write=has_legacy_write,
can_configure_acl=_can_configure_acl(db, user),
)
return success_response(detail) return success_response(detail)
@@ -70,6 +102,18 @@ async def put_sample_storage_cert_qc(
): ):
tenant_no = _get_tenant_no(db, user) tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user) user_id = _get_current_user_id(user)
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
# 先用空 sampleTypeKey 解析严格模式名单;类型在 upsert 内再收窄
preview = svc.get_detail(db, tenant_no, sample_code)
if preview is None:
return error_response(404, "样本不存在")
editable = acl_svc.resolve_editable_field_keys(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_type_key=preview.get("sampleTypeKey"),
has_legacy_write=has_legacy_write,
)
try: try:
detail = svc.upsert( detail = svc.upsert(
db, db,
@@ -78,6 +122,54 @@ async def put_sample_storage_cert_qc(
sample_code=sample_code, sample_code=sample_code,
payload=body.payload, payload=body.payload,
qc_work_order_id=body.qc_work_order_id, qc_work_order_id=body.qc_work_order_id,
editable_field_keys=editable,
)
svc.attach_acl_context(
db,
detail,
tenant_no=tenant_no,
user_id=user_id,
has_legacy_write=has_legacy_write,
can_configure_acl=_can_configure_acl(db, user),
)
return success_response(detail, message="保存成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-storage-cert-qc-acl/{scope}")
async def get_sample_storage_cert_qc_acl(
scope: str,
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
try:
detail = acl_svc.get_acl_detail(db, tenant_no, scope)
return success_response(detail)
except ValueError as e:
return error_response(400, str(e))
@router.put("/sample-storage-cert-qc-acl/{scope}")
async def put_sample_storage_cert_qc_acl(
scope: str,
body: AclUpsertBody,
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
detail = acl_svc.upsert_acl(
db,
tenant_no=tenant_no,
user_id=user_id,
scope=scope,
field_keys=body.field_keys,
user_ids=body.user_ids,
) )
return success_response(detail, message="保存成功") return success_response(detail, message="保存成功")
except ValueError as e: except ValueError as e:
+13 -1
View File
@@ -9,7 +9,19 @@ def unicode_ci(col):
def eq_unicode(left, right): def eq_unicode(left, right):
"""跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。""" """跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。
列与列:两侧 collate。列与字面量:仅列侧 collate,避免连接 collation
(常见 utf8mb4_general_ci)与列 collation(utf8mb4_unicode_ci)混用。
"""
left_is_str = isinstance(left, str)
right_is_str = isinstance(right, str)
if left_is_str and right_is_str:
return left == right
if left_is_str:
return left == unicode_ci(right)
if right_is_str:
return unicode_ci(left) == right
return unicode_ci(left) == unicode_ci(right) return unicode_ci(left) == unicode_ci(right)
+764
View File
@@ -0,0 +1,764 @@
"""实验室样本处理台账列表与补录 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_
from sqlalchemy.orm import Session
from app.core.data_scope import apply_data_scope
from app.core.utils import generate_uuid
from app.crud import sample_production_reception as prod_svc
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizLabSampleProcessSupplement,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleProductionReception,
BizSampleReception,
BizSampleReceptionCheckResult,
BizStorageReleaseReview,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationCollection,
BizResearchDonationParty,
BizResearchDonationSample,
)
from app.services import lab_sample_process as ledger
def _fmt_raw(dt: Optional[datetime]) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
def _normalize_bound(value: Optional[str], *, end_of_day: bool = False) -> Optional[datetime]:
text = (value or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(text[:19] if " " in text else text[:10], fmt)
if end_of_day and fmt == "%Y-%m-%d":
return dt.replace(hour=23, minute=59, second=59)
return dt
except ValueError:
continue
return None
def _type_ids_by_name(db: Session, tenant_no: str, type_name: str) -> List[str]:
rows = (
db.query(BizCellSampleType.id)
.filter(
BizCellSampleType.tenant_no == tenant_no,
BizCellSampleType.del_flag == 0,
BizCellSampleType.type_name == type_name,
)
.all()
)
return [r.id for r in rows if r.id]
def _get_supplement_map(
db: Session, tenant_no: str, codes: List[str]
) -> Dict[str, Dict[str, Any]]:
codes = [c for c in {(x or "").strip() for x in codes} if c]
if not codes:
return {}
rows = (
db.query(BizLabSampleProcessSupplement)
.filter(
BizLabSampleProcessSupplement.tenant_no == tenant_no,
BizLabSampleProcessSupplement.del_flag == 0,
BizLabSampleProcessSupplement.lab_storage_code.in_(codes),
)
.all()
)
out: Dict[str, Dict[str, Any]] = {}
for row in rows:
out[row.lab_storage_code] = {
"id": row.id,
"payload": ledger.parse_payload(row.payload),
"remark": row.remark or "",
"updateTime": _fmt_raw(row.update_time),
}
return out
def get_supplement(
db: Session, *, tenant_no: str, lab_storage_code: str
) -> Optional[Dict[str, Any]]:
code = (lab_storage_code or "").strip()
if not code:
return None
row = (
db.query(BizLabSampleProcessSupplement)
.filter(
BizLabSampleProcessSupplement.tenant_no == tenant_no,
BizLabSampleProcessSupplement.lab_storage_code == code,
BizLabSampleProcessSupplement.del_flag == 0,
)
.first()
)
if not row:
return None
return {
"id": row.id,
"labStorageCode": row.lab_storage_code,
"receptionSource": row.reception_source,
"sampleTypeName": row.sample_type_name or "",
"payload": ledger.parse_payload(row.payload),
"remark": row.remark or "",
"createTime": _fmt_raw(row.create_time),
"updateTime": _fmt_raw(row.update_time),
}
def upsert_supplement(
db: Session,
*,
tenant_no: str,
user_id: str,
dept_id: str,
lab_storage_code: str,
reception_source: str,
sample_type_name: str,
payload: Dict[str, Any],
remark: Optional[str] = None,
) -> Dict[str, Any]:
code = (lab_storage_code or "").strip()
if not code:
raise ValueError("实验室入库编码不能为空")
src = (reception_source or "").strip() or "cell_storage"
if src not in ("cell_storage", "research_donation"):
raise ValueError("来源类型无效")
type_name = (sample_type_name or "").strip()
clean_payload = {
str(k): ("" if v is None else str(v).strip())
for k, v in (payload or {}).items()
if str(k).strip()
}
row = (
db.query(BizLabSampleProcessSupplement)
.filter(
BizLabSampleProcessSupplement.tenant_no == tenant_no,
BizLabSampleProcessSupplement.lab_storage_code == code,
BizLabSampleProcessSupplement.del_flag == 0,
)
.first()
)
now = datetime.now()
if row:
row.reception_source = src
row.sample_type_name = type_name or row.sample_type_name
row.payload = ledger.dumps_payload(clean_payload)
if remark is not None:
row.remark = (remark or "").strip() or None
row.update_by = user_id
row.update_time = now
else:
row = BizLabSampleProcessSupplement(
id=generate_uuid(),
lab_storage_code=code,
reception_source=src,
sample_type_name=type_name or None,
payload=ledger.dumps_payload(clean_payload),
tenant_no=tenant_no,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
# 同步到新模块补充表/生产接收,保证实验室样本处理数据联动
from app.services.lab_sample_processing import sync_legacy_supplement_to_processing
sync_legacy_supplement_to_processing(
db,
tenant_no=tenant_no,
lab_storage_code=code,
sample_type_name=type_name,
payload=clean_payload,
operator_id=user_id,
)
db.commit()
db.refresh(row)
return get_supplement(db, tenant_no=tenant_no, lab_storage_code=code) or {}
def _check_flag_map(db: Session, tenant_no: str, reception_ids: List[str]) -> Dict[str, Dict[str, bool]]:
ids = [i for i in reception_ids if i]
if not ids:
return {}
rows = (
db.query(BizSampleReceptionCheckResult)
.filter(
BizSampleReceptionCheckResult.tenant_no == tenant_no,
BizSampleReceptionCheckResult.del_flag == 0,
BizSampleReceptionCheckResult.reception_id.in_(ids),
)
.all()
)
out: Dict[str, Dict[str, bool]] = {}
for r in rows:
bucket = out.setdefault(r.reception_id, {})
name = (r.item_name or "").strip()
if name:
bucket[name] = bool(r.checked)
return out
def _storage_status_map(db: Session, tenant_no: str, sample_codes: List[str]) -> Dict[str, str]:
codes = [c for c in sample_codes if c]
if not codes:
return {}
rows = (
db.query(BizStorageReleaseReview.sample_code, BizStorageReleaseReview.storage_status)
.filter(
BizStorageReleaseReview.tenant_no == tenant_no,
BizStorageReleaseReview.del_flag == 0,
BizStorageReleaseReview.sample_code.in_(codes),
)
.all()
)
labels = {"drk": "待入库", "yrk": "已入库"}
return {r.sample_code: labels.get(r.storage_status or "", r.storage_status or "") for r in rows}
def _hospital_name_map(db: Session, hospital_ids: List[str]) -> Dict[str, str]:
ids = [i for i in hospital_ids if i]
if not ids:
return {}
rows = (
db.query(BizCollectionHospital.id, BizCollectionHospital.hospital_name)
.filter(BizCollectionHospital.id.in_(ids), BizCollectionHospital.del_flag == 0)
.all()
)
return {r.id: r.hospital_name or "" for r in rows}
def _build_cell_system(
*,
tab_key: str,
rec: BizSampleReception,
sample: BizProtocolSample,
party: Optional[BizProtocolPartyCustomer],
coll: Optional[BizProtocolCollectionInfo],
protocol: Optional[BizCollectionProtocol],
prod: Optional[Dict[str, Any]],
hospital_name: str,
check_flags: Dict[str, bool],
storage_status: str,
qc_mother: Dict[str, str],
qc_process: Dict[str, str],
) -> Dict[str, Any]:
prod = prod or {}
maternal_time = coll.maternal_blood_collect_time if coll else None
clear_flag = None
for name, checked in check_flags.items():
if "标识清晰" in name or "样本信息正确" in name:
clear_flag = checked
break
base: Dict[str, Any] = {
"receptionId": rec.id,
"sampleCode": (rec.sample_code or sample.sample_code or "").strip(),
"labStorageCode": (rec.lab_storage_code or "").strip(),
"motherName": (party.mother_name if party else "") or "",
"collectionTime": ledger.format_cn_datetime(coll.sample_collect_time if coll else None),
"collectionTimeRaw": _fmt_raw(coll.sample_collect_time if coll else None),
"hospitalName": hospital_name,
"receptionTime": ledger.format_cn_datetime(rec.reception_time),
"receptionTimeRaw": _fmt_raw(rec.reception_time),
"receiveTemperature": rec.receive_temperature or "",
"handlerName": rec.handler_name or "",
"remarkReception": rec.remark or "",
"createTime": ledger.format_cn_datetime(rec.create_time),
"createTimeRaw": _fmt_raw(rec.create_time),
"operatorName": prod.get("handlerName") or "",
"remarkProcess": prod.get("remark") or "",
"storageStatus": storage_status,
}
if tab_key == "cell_hsc":
base.update(
{
"cellCode": (rec.lab_storage_code or "").strip(),
"hasMaternalBlood": "是" if maternal_time else "否",
"remeasureMaternalBlood": "",
"consentForm": ledger.yes_no_from_urls(
protocol.consent_form_urls if protocol else None
),
"transportPackingList": "",
"cordBloodReceiveConfirm": "",
"healthSurvey": ledger.yes_no_from_urls(
protocol.health_survey_form_urls if protocol else None
),
"labelClear": ledger.yes_no_from_bool(clear_flag) if clear_flag is not None else "",
"receiveCheckText": "",
"originalBloodWeightG": prod.get("originalBloodWeightG"),
"hesVolumeMl": prod.get("hesVolumeMl"),
"bloodType": prod.get("bloodType") or "",
"infectiousFourTestLabel": prod.get("infectiousFourTestResultLabel")
or ledger.infectious_label(prod.get("infectiousFourTestResult")),
"infectiousFourExternal": "",
"preEnrichNucleatedCount": "",
"enrichedBloodWeightG": prod.get("enrichedBloodWeightG"),
"postEnrichNucleatedCount": "",
"cryopreservationVolumeMl": prod.get("cryopreservationVolumeMl"),
"cd34Count1e4": "",
}
)
else: # cell_msc
base.update(
{
"labFlowCode": (rec.lab_storage_code or "").strip(),
"cordCollectTime": ledger.format_cn_datetime(
coll.sample_collect_time if coll else None
),
"transportTempC": rec.receive_temperature or "",
"cordReceiveTime": ledger.format_cn_datetime(rec.reception_time),
"inoculationAmount": prod.get("inoculationAmount") or "",
"cryopreservationTime": ledger.format_cn_datetime(prod.get("cryopreservationTime")),
"storageTotalTubes": prod.get("storageTotalTubes"),
"availableCellCount1e7": prod.get("availableCellCount1e7"),
"remark": rec.remark or prod.get("remark") or "",
"storageDateP2": "",
}
)
base.update(qc_process)
base.update(qc_mother)
return base
def _build_donation_system(
*,
tab_key: str,
rec: BizSampleReception,
sample: BizResearchDonationSample,
party: Optional[BizResearchDonationParty],
coll: Optional[BizResearchDonationCollection],
prod: Optional[Dict[str, Any]],
storage_status: str,
qc_mother: Dict[str, str],
qc_process: Dict[str, str],
) -> Dict[str, Any]:
prod = prod or {}
base: Dict[str, Any] = {
"receptionId": rec.id,
"sampleCode": (rec.sample_code or sample.sample_code or "").strip(),
"labStorageCode": (rec.lab_storage_code or "").strip(),
"motherName": (party.mother_name if party else "") or "",
"receptionTime": ledger.format_cn_datetime(rec.reception_time),
"receptionTimeRaw": _fmt_raw(rec.reception_time),
"receiveTemperature": rec.receive_temperature or "",
"handlerName": rec.handler_name or "",
"remarkReception": rec.remark or "",
"createTime": ledger.format_cn_datetime(rec.create_time),
"createTimeRaw": _fmt_raw(rec.create_time),
"operatorName": prod.get("handlerName") or "",
"remarkProcess": prod.get("remark") or "",
"storageStatus": storage_status,
}
if tab_key == "donation_hsc":
base.update(
{
"cellCode": (rec.lab_storage_code or "").strip(),
"labMaterialCode": "",
"inboundCode": (rec.lab_storage_code or "").strip(),
"discardReason": "",
"receiveCheckText": "",
"originalBloodVolumeMl": prod.get("originalBloodVolumeMl"),
"nucleatedCellCount1e8": prod.get("nucleatedCellCount1e8"),
"preOpNucleatedCount": "",
"infectiousFourTestLabel": prod.get("infectiousFourTestResultLabel")
or ledger.infectious_label(prod.get("infectiousFourTestResult")),
}
)
# 科研捐赠母血 5 项(不含核酸)
for k in (
"motherHbsag",
"motherAntiHcv",
"motherAntiTp",
"motherAntiHiv",
"motherCmvIgm",
):
if qc_mother.get(k):
base[k] = qc_mother[k]
else:
base.update(
{
"labFlowCode": (rec.lab_storage_code or "").strip(),
"cordCollectTime": ledger.format_cn_datetime(
coll.sample_collect_time if coll else None
),
"transportTempC": rec.receive_temperature or "",
"cordReceiveTime": ledger.format_cn_datetime(rec.reception_time),
"inoculationAmount": prod.get("inoculationAmount") or "",
"cryopreservationTime": ledger.format_cn_datetime(prod.get("cryopreservationTime")),
"storageTotalTubes": prod.get("storageTotalTubes"),
"availableCellCount1e7": prod.get("availableCellCount1e7"),
"remark": rec.remark or prod.get("remark") or "",
"storageDateP2": "",
}
)
base.update(qc_process)
base.update(qc_mother)
return base
def list_ledger(
db: Session,
*,
tab_key: str,
page: int = 1,
page_size: int = 10,
tenant_no: str,
lab_storage_code: Optional[str] = None,
mother_name: Optional[str] = None,
create_time_start: Optional[str] = None,
create_time_end: Optional[str] = None,
user_info=None,
) -> Tuple[List[Dict[str, Any]], int]:
meta = ledger.TAB_META.get(tab_key)
if not meta:
raise ValueError("无效的 Tab")
source = meta["source"]
type_name = meta["sampleTypeName"]
type_ids = _type_ids_by_name(db, tenant_no, type_name)
if not type_ids:
return [], 0
q = db.query(BizSampleReception).filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == source,
BizSampleReception.lab_storage_code.isnot(None),
BizSampleReception.lab_storage_code != "",
BizSampleReception.accept_conclusion == "js",
)
code_kw = (lab_storage_code or "").strip()
if code_kw:
q = q.filter(BizSampleReception.lab_storage_code.like(f"%{code_kw}%"))
start = _normalize_bound(create_time_start)
end = _normalize_bound(create_time_end, end_of_day=True)
if start:
q = q.filter(BizSampleReception.create_time >= start)
if end:
q = q.filter(BizSampleReception.create_time <= end)
if user_info:
q = apply_data_scope(q, BizSampleReception, user_info, db, tenant_no)
mother_kw = (mother_name or "").strip()
# 按来源限制样本类型,并可选母亲姓名
if source == "cell_storage":
sample_q = (
db.query(BizProtocolSample.id)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.tenant_no == tenant_no,
),
)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_type_id.in_(type_ids),
)
)
if mother_kw:
sample_q = sample_q.join(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id
== BizProtocolSample.collection_protocol_id,
BizProtocolPartyCustomer.del_flag == 0,
BizProtocolPartyCustomer.tenant_no == tenant_no,
),
).filter(BizProtocolPartyCustomer.mother_name.like(f"%{mother_kw}%"))
sample_ids = [r.id for r in sample_q.all()]
if not sample_ids:
return [], 0
q = q.filter(BizSampleReception.protocol_sample_id.in_(sample_ids))
else:
sample_q = (
db.query(BizResearchDonationSample.id)
.join(
BizResearchDonation,
and_(
BizResearchDonation.id == BizResearchDonationSample.research_donation_id,
BizResearchDonation.del_flag == 0,
BizResearchDonation.tenant_no == tenant_no,
),
)
.filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.sample_type_id.in_(type_ids),
)
)
if mother_kw:
sample_q = sample_q.join(
BizResearchDonationParty,
and_(
BizResearchDonationParty.research_donation_id
== BizResearchDonationSample.research_donation_id,
BizResearchDonationParty.del_flag == 0,
BizResearchDonationParty.tenant_no == tenant_no,
),
).filter(BizResearchDonationParty.mother_name.like(f"%{mother_kw}%"))
sample_ids = [r.id for r in sample_q.all()]
if not sample_ids:
return [], 0
q = q.filter(BizSampleReception.donation_sample_id.in_(sample_ids))
total = q.count()
page = max(1, int(page or 1))
page_size = min(max(1, int(page_size or 10)), 200)
receptions = (
q.order_by(BizSampleReception.create_time.desc(), BizSampleReception.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
if not receptions:
return [], int(total)
reception_ids = [r.id for r in receptions]
codes = [(r.lab_storage_code or "").strip() for r in receptions]
sample_codes = [(r.sample_code or "").strip() for r in receptions]
prod_map = _prod_map(db, tenant_no, reception_ids)
check_map = _check_flag_map(db, tenant_no, reception_ids)
storage_map = _storage_status_map(db, tenant_no, [c for c in sample_codes if c])
sup_map = _get_supplement_map(db, tenant_no, codes)
items: List[Dict[str, Any]] = []
if source == "cell_storage":
items = _assemble_cell_rows(
db,
tenant_no=tenant_no,
tab_key=tab_key,
receptions=receptions,
prod_map=prod_map,
check_map=check_map,
storage_map=storage_map,
sup_map=sup_map,
)
else:
items = _assemble_donation_rows(
db,
tenant_no=tenant_no,
tab_key=tab_key,
receptions=receptions,
prod_map=prod_map,
storage_map=storage_map,
sup_map=sup_map,
)
return items, int(total)
def _prod_map(db: Session, tenant_no: str, reception_ids: List[str]) -> Dict[str, Dict[str, Any]]:
rows = (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.del_flag == 0,
BizSampleProductionReception.reception_id.in_(reception_ids),
)
.all()
)
return {r.reception_id: prod_svc._row_to_dict(r) for r in rows}
def _assemble_cell_rows(
db: Session,
*,
tenant_no: str,
tab_key: str,
receptions: List[BizSampleReception],
prod_map: Dict[str, Dict[str, Any]],
check_map: Dict[str, Dict[str, bool]],
storage_map: Dict[str, str],
sup_map: Dict[str, Dict[str, Any]],
) -> List[Dict[str, Any]]:
sample_ids = [r.protocol_sample_id for r in receptions if r.protocol_sample_id]
samples = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.id.in_(sample_ids),
)
.all()
if sample_ids
else []
)
sample_map = {s.id: s for s in samples}
protocol_ids = list({s.collection_protocol_id for s in samples if s.collection_protocol_id})
parties = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.tenant_no == tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids),
)
.all()
if protocol_ids
else []
)
party_map = {p.collection_protocol_id: p for p in parties}
colls = (
db.query(BizProtocolCollectionInfo)
.filter(
BizProtocolCollectionInfo.tenant_no == tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
BizProtocolCollectionInfo.collection_protocol_id.in_(protocol_ids),
)
.all()
if protocol_ids
else []
)
coll_map = {c.collection_protocol_id: c for c in colls}
protocols = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.id.in_(protocol_ids),
)
.all()
if protocol_ids
else []
)
protocol_map = {p.id: p for p in protocols}
hospital_ids = [c.hospital_id for c in colls if c.hospital_id]
hospital_map = _hospital_name_map(db, hospital_ids)
out: List[Dict[str, Any]] = []
for rec in receptions:
sample = sample_map.get(rec.protocol_sample_id or "")
if not sample:
continue
pid = sample.collection_protocol_id
sample_code = (rec.sample_code or sample.sample_code or "").strip()
tasks = ledger.load_qc_tasks_for_sample(db, tenant_no, sample_code)
qc_mother = ledger.extract_mother_blood(tasks, include_nucleic=True)
qc_process = ledger.extract_process_tests(tasks)
system = _build_cell_system(
tab_key=tab_key,
rec=rec,
sample=sample,
party=party_map.get(pid),
coll=coll_map.get(pid),
protocol=protocol_map.get(pid),
prod=prod_map.get(rec.id),
hospital_name=hospital_map.get((coll_map.get(pid).hospital_id if coll_map.get(pid) else "") or "", ""),
check_flags=check_map.get(rec.id, {}),
storage_status=storage_map.get(sample_code, ""),
qc_mother=qc_mother,
qc_process=qc_process,
)
lab_code = system.get("labStorageCode") or ""
merged = ledger.merge_row(
tab_key=tab_key,
system_values=system,
supplement=(sup_map.get(lab_code) or {}).get("payload"),
)
merged["supplementId"] = (sup_map.get(lab_code) or {}).get("id") or ""
merged["hasSupplement"] = bool((sup_map.get(lab_code) or {}).get("payload"))
out.append(merged)
return out
def _assemble_donation_rows(
db: Session,
*,
tenant_no: str,
tab_key: str,
receptions: List[BizSampleReception],
prod_map: Dict[str, Dict[str, Any]],
storage_map: Dict[str, str],
sup_map: Dict[str, Dict[str, Any]],
) -> List[Dict[str, Any]]:
sample_ids = [r.donation_sample_id for r in receptions if r.donation_sample_id]
samples = (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
BizResearchDonationSample.id.in_(sample_ids),
)
.all()
if sample_ids
else []
)
sample_map = {s.id: s for s in samples}
donation_ids = list({s.research_donation_id for s in samples if s.research_donation_id})
parties = (
db.query(BizResearchDonationParty)
.filter(
BizResearchDonationParty.tenant_no == tenant_no,
BizResearchDonationParty.del_flag == 0,
BizResearchDonationParty.research_donation_id.in_(donation_ids),
)
.all()
if donation_ids
else []
)
party_map = {p.research_donation_id: p for p in parties}
colls = (
db.query(BizResearchDonationCollection)
.filter(
BizResearchDonationCollection.tenant_no == tenant_no,
BizResearchDonationCollection.del_flag == 0,
BizResearchDonationCollection.research_donation_id.in_(donation_ids),
)
.all()
if donation_ids
else []
)
coll_map = {c.research_donation_id: c for c in colls}
out: List[Dict[str, Any]] = []
for rec in receptions:
sample = sample_map.get(rec.donation_sample_id or "")
if not sample:
continue
did = sample.research_donation_id
sample_code = (rec.sample_code or sample.sample_code or "").strip()
tasks = ledger.load_qc_tasks_for_sample(db, tenant_no, sample_code)
qc_mother = ledger.extract_mother_blood(tasks, include_nucleic=True)
qc_process = ledger.extract_process_tests(tasks)
system = _build_donation_system(
tab_key=tab_key,
rec=rec,
sample=sample,
party=party_map.get(did),
coll=coll_map.get(did),
prod=prod_map.get(rec.id),
storage_status=storage_map.get(sample_code, ""),
qc_mother=qc_mother,
qc_process=qc_process,
)
lab_code = system.get("labStorageCode") or ""
merged = ledger.merge_row(
tab_key=tab_key,
system_values=system,
supplement=(sup_map.get(lab_code) or {}).get("payload"),
)
merged["supplementId"] = (sup_map.get(lab_code) or {}).get("id") or ""
merged["hasSupplement"] = bool((sup_map.get(lab_code) or {}).get("payload"))
out.append(merged)
return out
+34 -3
View File
@@ -15,12 +15,14 @@ from app.models.models import (
BizProtocolSample, BizProtocolSample,
BizSampleStorageCertQc, BizSampleStorageCertQc,
) )
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.services.sample_storage_cert_qc import ( from app.services.sample_storage_cert_qc import (
dumps_payload, dumps_payload,
empty_detail, empty_detail,
loads_payload, loads_payload,
normalize_payload, merge_payload_with_acl,
resolve_sample_type_key, resolve_sample_type_key,
sanitize_payload_for_type,
) )
from app.services.sample_storage_cert_qc_prefill import collect_prefill from app.services.sample_storage_cert_qc_prefill import collect_prefill
@@ -44,7 +46,7 @@ def _row_to_dict(
"collectionProtocolId": row.collection_protocol_id or "", "collectionProtocolId": row.collection_protocol_id or "",
"qcWorkOrderId": row.qc_work_order_id or "", "qcWorkOrderId": row.qc_work_order_id or "",
"filledFlag": str(row.filled_flag or "0") == "1", "filledFlag": str(row.filled_flag or "0") == "1",
"payload": loads_payload(row.payload), "payload": sanitize_payload_for_type(loads_payload(row.payload), row.sample_type_key),
"createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "", "createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
"updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "", "updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
} }
@@ -165,6 +167,7 @@ def upsert(
sample_code: str, sample_code: str,
payload: Any, payload: Any,
qc_work_order_id: Optional[str] = None, qc_work_order_id: Optional[str] = None,
editable_field_keys: Optional[list] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
ctx = _load_sample_context(db, tenant_no, sample_code) ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx: if not ctx:
@@ -173,7 +176,6 @@ def upsert(
key = resolve_sample_type_key(type_name) key = resolve_sample_type_key(type_name)
if not key: if not key:
raise ValueError("该样本类型不支持填写储存证质检信息") raise ValueError("该样本类型不支持填写储存证质检信息")
clean = normalize_payload(payload)
now = datetime.now() now = datetime.now()
row = ( row = (
db.query(BizSampleStorageCertQc) db.query(BizSampleStorageCertQc)
@@ -184,6 +186,14 @@ def upsert(
) )
.first() .first()
) )
existing = loads_payload(row.payload) if row else {}
allowed = list(editable_field_keys or [])
clean = merge_payload_with_acl(
existing=existing,
incoming=payload,
editable_keys=allowed,
sample_type_key=key,
)
if not row: if not row:
row = BizSampleStorageCertQc( row = BizSampleStorageCertQc(
id=generate_uuid(), id=generate_uuid(),
@@ -220,3 +230,24 @@ def upsert(
mother_name=mother_name, mother_name=mother_name,
sample_type_name=type_name, sample_type_name=type_name,
) )
def attach_acl_context(
db: Session,
detail: Dict[str, Any],
*,
tenant_no: str,
user_id: str,
has_legacy_write: bool,
can_configure_acl: bool,
) -> Dict[str, Any]:
detail["editableFieldKeys"] = acl_svc.resolve_editable_field_keys(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_type_key=detail.get("sampleTypeKey"),
has_legacy_write=has_legacy_write,
)
detail["canConfigureAcl"] = bool(can_configure_acl)
detail["aclStrict"] = acl_svc.is_acl_strict(db, tenant_no)
return detail
@@ -0,0 +1,272 @@
"""储存证质检字段 ACL CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
from sqlalchemy.orm import Session
from app.core.db_collation import eq_unicode, unicode_ci
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import BizStorageCertQcAcl, BizStorageCertQcAclUser, SysUser
from app.services.sample_storage_cert_qc import (
ACL_SCOPES,
ALL_STORAGE_CERT_QC_FIELD_KEYS,
dumps_field_keys,
legal_field_keys_for_type,
loads_field_keys,
)
def is_acl_strict(db: Session, tenant_no: str) -> bool:
row = (
db.query(BizStorageCertQcAcl.id)
.filter(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.configured_flag, "1"),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
return row is not None
def resolve_editable_field_keys(
db: Session,
*,
tenant_no: str,
user_id: str,
sample_type_key: Optional[str],
has_legacy_write: bool,
) -> List[str]:
legal = legal_field_keys_for_type(sample_type_key)
if not is_acl_strict(db, tenant_no):
return sorted(legal) if has_legacy_write else []
uid = (user_id or "").strip()
if not uid:
return []
acls = (
db.query(BizStorageCertQcAcl)
.filter(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.configured_flag, "1"),
BizStorageCertQcAcl.del_flag == 0,
)
.all()
)
if not acls:
return []
acl_ids = [row.id for row in acls]
membership = (
db.query(BizStorageCertQcAclUser.acl_id)
.filter(
eq_unicode(BizStorageCertQcAclUser.tenant_no, tenant_no),
unicode_ci(BizStorageCertQcAclUser.acl_id).in_(acl_ids),
eq_unicode(BizStorageCertQcAclUser.user_id, uid),
BizStorageCertQcAclUser.del_flag == 0,
)
.all()
)
member_acl_ids = {row[0] for row in membership}
keys: Set[str] = set()
for acl in acls:
if acl.id not in member_acl_ids:
continue
keys.update(loads_field_keys(acl.field_keys))
return sorted(keys & legal)
def _validate_scope(scope: str) -> str:
value = (scope or "").strip()
if value not in ACL_SCOPES:
raise ValueError("scope 必须为 qc 或 production")
return value
def _validate_field_keys(field_keys: Any) -> List[str]:
if not isinstance(field_keys, list):
raise ValueError("fieldKeys 必须为数组")
out: List[str] = []
seen = set()
for item in field_keys:
key = str(item or "").strip()
if not key or key in seen:
continue
if key not in ALL_STORAGE_CERT_QC_FIELD_KEYS:
raise ValueError(f"非法字段: {key}")
seen.add(key)
out.append(key)
return out
def _validate_user_ids(db: Session, tenant_no: str, user_ids: Any) -> List[str]:
if not isinstance(user_ids, list):
raise ValueError("userIds 必须为数组")
out: List[str] = []
seen = set()
for item in user_ids:
uid = str(item or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
out.append(uid)
if not out:
return []
rows = (
db.query(SysUser.id)
.filter(
eq_unicode(SysUser.tenant_no, tenant_no),
unicode_ci(SysUser.id).in_(out),
SysUser.del_flag == 0,
)
.all()
)
found = {row[0] for row in rows}
missing = [uid for uid in out if uid not in found]
if missing:
raise ValueError(f"用户不存在或不属于当前租户: {','.join(missing[:5])}")
return out
def get_acl_detail(db: Session, tenant_no: str, scope: str) -> Dict[str, Any]:
scope_key = _validate_scope(scope)
row = (
db.query(BizStorageCertQcAcl)
.filter(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.scope, scope_key),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
if not row:
return {
"scope": scope_key,
"fieldKeys": [],
"userIds": [],
"users": [],
"configuredFlag": False,
"aclStrict": is_acl_strict(db, tenant_no),
}
users = (
db.query(BizStorageCertQcAclUser, SysUser)
.outerjoin(
SysUser,
eq_unicode(SysUser.id, BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
)
.filter(
eq_unicode(BizStorageCertQcAclUser.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAclUser.acl_id, row.id),
BizStorageCertQcAclUser.del_flag == 0,
)
.order_by(BizStorageCertQcAclUser.order_num.asc(), BizStorageCertQcAclUser.create_time.asc())
.all()
)
user_ids: List[str] = []
user_rows: List[Dict[str, str]] = []
for link, user in users:
uid = link.user_id or ""
if not uid:
continue
user_ids.append(uid)
user_rows.append(
{
"id": uid,
"nickName": (user.nick_name if user else "") or "",
"userName": (user.user_no if user else "") or "",
}
)
return {
"scope": scope_key,
"fieldKeys": loads_field_keys(row.field_keys),
"userIds": user_ids,
"users": user_rows,
"configuredFlag": str(row.configured_flag or "0") == "1",
"aclStrict": is_acl_strict(db, tenant_no),
}
def upsert_acl(
db: Session,
*,
tenant_no: str,
user_id: str,
scope: str,
field_keys: Any,
user_ids: Any,
) -> Dict[str, Any]:
scope_key = _validate_scope(scope)
keys = _validate_field_keys(field_keys)
users = _validate_user_ids(db, tenant_no, user_ids)
now = datetime.now()
row = (
db.query(BizStorageCertQcAcl)
.filter(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.scope, scope_key),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
if not row:
row = BizStorageCertQcAcl(
id=generate_uuid(),
scope=scope_key,
field_keys=dumps_field_keys(keys),
configured_flag="1",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.flush()
else:
row.field_keys = dumps_field_keys(keys)
row.configured_flag = "1"
row.update_by = user_id
row.update_time = now
def _build_row(new_id: str):
return BizStorageCertQcAclUser(
id=new_id,
acl_id=row.id,
user_id="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
def _apply_fields(link, item: Dict[str, Any], index: int, ts: datetime):
link.user_id = str(item.get("userId") or "").strip()
link.tenant_no = tenant_no
link.order_num = index
link.update_by = user_id
link.update_time = ts
sync_child_rows(
db,
BizStorageCertQcAclUser,
parent_attr="acl_id",
parent_id=row.id,
items=[{"userId": uid} for uid in users],
build_row=_build_row,
apply_fields=_apply_fields,
tenant_no=tenant_no,
user_id=user_id,
now=now,
natural_key=lambda item: str(item.get("userId") or "").strip(),
row_natural_key=lambda link: str(getattr(link, "user_id", "") or "").strip(),
duplicate_message="人员重复",
foreign_message="人员记录不属于当前 ACL",
)
db.commit()
return get_acl_detail(db, tenant_no, scope_key)
+3 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401 from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, project_management_router from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, lab_sample_process_router, lab_sample_processing_router, project_management_router
@asynccontextmanager @asynccontextmanager
@@ -157,6 +157,8 @@ app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX) app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX)
app.include_router(lab_sample_process_router, prefix=settings.API_V1_PREFIX)
app.include_router(lab_sample_processing_router, prefix=settings.API_V1_PREFIX)
app.include_router(project_management_router, prefix=settings.API_V1_PREFIX) app.include_router(project_management_router, prefix=settings.API_V1_PREFIX)
+82
View File
@@ -1779,6 +1779,45 @@ class BizSampleStorageCertQc(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageCertQcAcl(Base):
"""储存证质检字段 ACL 主表 biz_storage_cert_qc_acl"""
__tablename__ = "biz_storage_cert_qc_acl"
id = Column(String(64), primary_key=True, comment="主键ID")
scope = Column(String(32), nullable=False, comment="qc|production")
field_keys = Column(Text, nullable=False, comment="可编辑字段 key JSON 数组")
configured_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已正式配置 0否1是")
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="删除标记")
class BizStorageCertQcAclUser(Base):
"""储存证质检字段 ACL 人员子表 biz_storage_cert_qc_acl_user"""
__tablename__ = "biz_storage_cert_qc_acl_user"
id = Column(String(64), primary_key=True, comment="主键ID")
acl_id = Column(String(64), nullable=False, comment="ACL 主表ID")
user_id = Column(String(64), nullable=False, comment="可编辑用户ID")
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="删除标记")
class BizSampleCodeCorrectionLog(Base): class BizSampleCodeCorrectionLog(Base):
"""样本编码纠错审计日志 biz_sample_code_correction_log""" """样本编码纠错审计日志 biz_sample_code_correction_log"""
@@ -1930,6 +1969,26 @@ class BizSampleReception(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizLabSampleProcessingExtra(Base):
"""实验室样本处理数据补充字段 biz_lab_sample_processing_extra"""
__tablename__ = "biz_lab_sample_processing_extra"
id = Column(String(64), primary_key=True, comment="主键ID")
dataset = Column(String(16), nullable=False, comment="hsc|ubmc|msc")
sample_code = Column(String(32), nullable=False, comment="样本编码")
payload_json = Column(Text, nullable=True, comment="补充字段 JSON")
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="删除标记")
class BizSampleProductionReception(Base): class BizSampleProductionReception(Base):
"""生产部门样本接收登记 biz_sample_production_reception""" """生产部门样本接收登记 biz_sample_production_reception"""
@@ -1968,6 +2027,29 @@ class BizSampleProductionReception(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizLabSampleProcessSupplement(Base):
"""实验室样本处理台账补录 biz_lab_sample_process_supplement(键:tenant_no + lab_storage_code)"""
__tablename__ = "biz_lab_sample_process_supplement"
id = Column(String(64), primary_key=True, comment="主键ID")
lab_storage_code = Column(String(32), nullable=False, comment="实验室入库编码")
reception_source = Column(
String(20), nullable=False, default="cell_storage", comment="cell_storage|research_donation"
)
sample_type_name = Column(String(100), nullable=True, comment="样本类型名称快照")
payload = Column(Text, nullable=False, comment="补录字段 JSON")
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="删除标记")
class BizSampleSmsNotification(Base): class BizSampleSmsNotification(Base):
__tablename__ = "biz_sample_sms_notification" __tablename__ = "biz_sample_sms_notification"
+374
View File
@@ -0,0 +1,374 @@
"""实验室样本处理台账:字段合并、质检参数匹配、中文时间格式。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict, Iterable, List, Optional, Set
from sqlalchemy.orm import Session
from app.models.models import (
BizQcTask,
BizQcWorkOrder,
BizQcWorkOrderInspectionBatch,
)
from app.models.qc_param import BizQcTaskResult
from app.services.sample_storage_cert_qc_prefill import (
_MOTHER_SEROLOGY_RULES,
_process_qual_from_tasks,
_serology_from_tasks,
_stamp,
batch_kind_from_name,
normalize_negative_positive,
qual_yin_yang,
)
SAMPLE_TYPE_PERINATAL_HSC = "围产期造血干细胞"
SAMPLE_TYPE_UC_MSC = "脐带间充质干细胞"
TAB_KEYS = (
"cell_hsc",
"donation_hsc",
"cell_msc",
"donation_msc",
)
TAB_META = {
"cell_hsc": {
"source": "cell_storage",
"sampleTypeName": SAMPLE_TYPE_PERINATAL_HSC,
"label": "细胞存储·围产期造血干细胞",
},
"donation_hsc": {
"source": "research_donation",
"sampleTypeName": SAMPLE_TYPE_PERINATAL_HSC,
"label": "科研捐赠·围产期造血干细胞",
},
"cell_msc": {
"source": "cell_storage",
"sampleTypeName": SAMPLE_TYPE_UC_MSC,
"label": "细胞存储·脐带间充质干细胞",
},
"donation_msc": {
"source": "research_donation",
"sampleTypeName": SAMPLE_TYPE_UC_MSC,
"label": "科研捐赠·脐带间充质干细胞",
},
}
# 各 Tab 可补录字段(系统空或口径需覆盖时写入)
SUPPLEMENT_KEYS: Dict[str, List[str]] = {
"cell_hsc": [
"receiveCheckText",
"hasMaternalBlood",
"remeasureMaternalBlood",
"transportPackingList",
"cordBloodReceiveConfirm",
"infectiousFourExternal",
"preEnrichNucleatedCount",
"postEnrichNucleatedCount",
"cd34Count1e4",
"remarkProcess",
],
"donation_hsc": [
"labMaterialCode",
"storageStatus",
"discardReason",
"receiveCheckText",
"preOpNucleatedCount",
"infectiousFourTestLabel",
"motherHbsag",
"motherAntiHcv",
"motherAntiTp",
"motherAntiHiv",
"motherCmvIgm",
"remarkProcess",
],
"cell_msc": [
"storageStatus",
"endotoxinTest",
"bacteriaTest",
"fungusTest",
"mycoplasmaTest",
"motherHbsag",
"motherHbvDna",
"motherAntiHcv",
"motherHcvRna",
"motherAntiTp",
"motherCmvIgm",
"motherAntiHiv",
"motherHiv1Rna",
"storageDateP2",
"remark",
],
"donation_msc": [
"storageStatus",
"storageTotalTubes",
"availableCellCount1e7",
"endotoxinTest",
"bacteriaTest",
"fungusTest",
"mycoplasmaTest",
"motherHbsag",
"motherHbvDna",
"motherAntiHcv",
"motherHcvRna",
"motherAntiTp",
"motherCmvIgm",
"motherAntiHiv",
"motherHiv1Rna",
"storageDateP2",
"remark",
],
}
# 补录优先覆盖系统推导值的字段
SUPPLEMENT_OVERRIDE_KEYS: Dict[str, Set[str]] = {
"cell_hsc": {"receiveCheckText"},
"donation_hsc": {
"receiveCheckText",
"storageStatus",
"infectiousFourTestLabel",
"motherHbsag",
"motherAntiHcv",
"motherAntiTp",
"motherAntiHiv",
"motherCmvIgm",
},
"cell_msc": {
"storageStatus",
"endotoxinTest",
"bacteriaTest",
"fungusTest",
"mycoplasmaTest",
"motherHbsag",
"motherHbvDna",
"motherAntiHcv",
"motherHcvRna",
"motherAntiTp",
"motherCmvIgm",
"motherAntiHiv",
"motherHiv1Rna",
"storageDateP2",
},
"donation_msc": {
"storageStatus",
"storageTotalTubes",
"availableCellCount1e7",
"endotoxinTest",
"bacteriaTest",
"fungusTest",
"mycoplasmaTest",
"motherHbsag",
"motherHbvDna",
"motherAntiHcv",
"motherHcvRna",
"motherAntiTp",
"motherCmvIgm",
"motherAntiHiv",
"motherHiv1Rna",
"storageDateP2",
},
}
def format_cn_datetime(value: Any) -> str:
"""统一展示:2026年11月12日 00时00分00秒"""
if value is None or value == "":
return ""
if isinstance(value, datetime):
dt = value
else:
text = str(value).strip().replace("T", " ")
if not text:
return ""
dt = None
for candidate, fmt in (
(text[:19], "%Y-%m-%d %H:%M:%S"),
(text[:16], "%Y-%m-%d %H:%M"),
(text[:10], "%Y-%m-%d"),
):
try:
dt = datetime.strptime(candidate, fmt)
break
except ValueError:
continue
if dt is None:
return text
return (
f"{dt.year:04d}年{dt.month:02d}月{dt.day:02d}日 "
f"{dt.hour:02d}时{dt.minute:02d}分{dt.second:02d}秒"
)
def parse_payload(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return dict(raw)
if not raw:
return {}
try:
data = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def dumps_payload(payload: Dict[str, Any]) -> str:
return json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":"))
def yes_no_from_urls(urls: Optional[str]) -> str:
return "是" if (urls or "").strip() else "否"
def yes_no_from_bool(flag: Optional[bool]) -> str:
if flag is None:
return ""
return "是" if flag else "否"
def merge_row(
*,
tab_key: str,
system_values: Dict[str, Any],
supplement: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""系统值优先;override 键补录优先;空用补录填。"""
out = dict(system_values or {})
sup = supplement or {}
overrides = SUPPLEMENT_OVERRIDE_KEYS.get(tab_key, set())
for key, val in sup.items():
text = "" if val is None else str(val).strip()
if not text:
continue
cur = out.get(key)
cur_empty = cur is None or (isinstance(cur, str) and not cur.strip())
if key in overrides or cur_empty:
out[key] = text
return out
def load_qc_tasks_for_sample(db: Session, tenant_no: str, sample_code: str) -> List[Dict[str, Any]]:
code = (sample_code or "").strip()
if not code:
return []
rows = (
db.query(
BizQcTask.id,
BizQcTask.item_name,
BizQcTask.completed_time,
BizQcTask.update_time,
BizQcWorkOrderInspectionBatch.batch_name,
BizQcTaskResult.param_name,
BizQcTaskResult.param_value,
)
.join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
.outerjoin(
BizQcWorkOrderInspectionBatch,
BizQcWorkOrderInspectionBatch.id == BizQcTask.batch_id,
)
.outerjoin(
BizQcTaskResult,
(BizQcTaskResult.task_id == BizQcTask.id) & (BizQcTaskResult.del_flag == 0),
)
.filter(
BizQcWorkOrder.tenant_no == tenant_no,
BizQcWorkOrder.sample_code == code,
BizQcWorkOrder.del_flag == 0,
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
BizQcTask.task_status == "ywc",
)
.all()
)
grouped: Dict[str, Dict[str, Any]] = {}
for row in rows:
task = grouped.setdefault(
row.id,
{
"item_name": row.item_name or "",
"batch_kind": batch_kind_from_name(row.batch_name),
"stamp": _stamp(row.completed_time, row.update_time),
"params": [],
"values": [],
},
)
if (row.param_name or "").strip() or (row.param_value or "").strip():
task["params"].append((row.param_name or "", row.param_value or ""))
if (row.param_value or "").strip():
task["values"].append(row.param_value or "")
return list(grouped.values())
def extract_mother_blood(tasks: List[Dict[str, Any]], *, include_nucleic: bool = True) -> Dict[str, str]:
rules = _MOTHER_SEROLOGY_RULES
if not include_nucleic:
rules = tuple(
r
for r in rules
if r[0] not in {"mother_hbv_dna", "mother_hcv_rna", "mother_hiv1_rna"}
)
raw = _serology_from_tasks(tasks, rules)
mapping = {
"mother_hbsag": "motherHbsag",
"mother_hbv_dna": "motherHbvDna",
"mother_anti_hcv": "motherAntiHcv",
"mother_hcv_rna": "motherHcvRna",
"mother_anti_tp": "motherAntiTp",
"mother_cmv_igm": "motherCmvIgm",
"mother_anti_hiv": "motherAntiHiv",
"mother_hiv1_rna": "motherHiv1Rna",
}
out: Dict[str, str] = {}
for src, dst in mapping.items():
val = normalize_negative_positive(raw.get(src) or "") or (raw.get(src) or "")
if val:
# 台账 Excel 多用「阴」
if val in ("阴性", "阴"):
out[dst] = "阴"
elif val in ("阳性", "阳"):
out[dst] = "阳"
else:
out[dst] = val
return out
def extract_process_tests(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
raw = _process_qual_from_tasks(tasks)
bacteria_values: List[str] = []
for task in tasks:
name = task.get("item_name") or ""
params = task.get("params") or []
hit = "细菌" in name or any("细菌" in (pn or "") for pn, _ in params)
if not hit:
continue
for _, pv in params:
bacteria_values.append(pv)
bacteria_values.extend(task.get("values") or [])
bacteria = qual_yin_yang(bacteria_values)
out = {
"endotoxinTest": _to_yin(raw.get("endotoxin_test")),
"fungusTest": _to_yin(raw.get("fungus_test")),
"mycoplasmaTest": _to_yin(raw.get("mycoplasma_test")),
}
if bacteria:
out["bacteriaTest"] = _to_yin(bacteria)
return {k: v for k, v in out.items() if v}
def _to_yin(value: Optional[str]) -> str:
text = (value or "").strip()
if text in ("阴性", "阴"):
return "阴"
if text in ("阳性", "阳"):
return "阳"
return text
def infectious_label(code: Optional[str]) -> str:
c = (code or "").strip()
if c == "zc":
return "正常"
if c == "bzc":
return "不正常"
return c
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,176 @@
"""实验室样本处理数据 — 字段目录与部门归属。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
DATASET_HSC = "hsc"
DATASET_UBMC = "ubmc"
DATASET_MSC = "msc"
DATASETS = (DATASET_HSC, DATASET_UBMC, DATASET_MSC)
DEPT_MARKET = "market"
DEPT_PRODUCTION = "production"
DEPT_QC = "qc"
DEPTS = (DEPT_MARKET, DEPT_PRODUCTION, DEPT_QC)
PERM_LIST = "lab:sample-processing:list"
PERM_MARKET_EDIT = "lab:sample-processing:market:edit"
PERM_PRODUCTION_EDIT = "lab:sample-processing:production:edit"
PERM_QC_EDIT = "lab:sample-processing:qc:edit"
PERM_MANAGER = "lab:sample-processing:manager"
DEPT_EDIT_PERMS = {
DEPT_MARKET: PERM_MARKET_EDIT,
DEPT_PRODUCTION: PERM_PRODUCTION_EDIT,
DEPT_QC: PERM_QC_EDIT,
}
SOURCE_PROTOCOL = "protocol"
SOURCE_RECEPTION = "reception"
SOURCE_PRODUCTION = "production_reception"
SOURCE_EXTRA = "extra"
@dataclass(frozen=True)
class FieldDef:
key: str
label: str
dept: str
source: str
attr: Optional[str] = None # ORM attr on source object when applicable
def _f(key: str, label: str, dept: str, source: str, attr: Optional[str] = None) -> FieldDef:
return FieldDef(key=key, label=label, dept=dept, source=source, attr=attr or key)
HSC_FIELDS: Tuple[FieldDef, ...] = (
_f("sampleCode", "细胞编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"),
_f("motherName", "准妈妈姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"),
_f("collectTime", "采集时间", DEPT_MARKET, SOURCE_PROTOCOL, "sample_collect_time"),
_f("collectHospital", "采集医院", DEPT_MARKET, SOURCE_PROTOCOL, "hospital_name"),
_f("receptionTime", "接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"),
_f("receiveTemperature", "样品温度到达/℃", DEPT_PRODUCTION, SOURCE_RECEPTION, "receive_temperature"),
_f("receiveCheck", "收样检查", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("hasMaternalBlood", "是否有母血", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("remeasureMaternalBlood", "是否重采母血", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("hasConsent", "知情同意书或协议书", DEPT_MARKET, SOURCE_EXTRA),
_f("hasPackingList", "脐血运输装箱清单", DEPT_MARKET, SOURCE_EXTRA),
_f("hasReceiveConfirm", "脐带血接收确认单", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("hasHealthSurvey", "孕妇健康调查表", DEPT_MARKET, SOURCE_EXTRA),
_f("labelClear", "标识清晰", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"),
_f("receptionRemark", "备注", DEPT_PRODUCTION, SOURCE_RECEPTION, "remark"),
_f("originalBloodWeightG", "原血重量/g", DEPT_PRODUCTION, SOURCE_PRODUCTION, "original_blood_weight_g"),
_f("hesVolumeMl", "HES用量/ml", DEPT_PRODUCTION, SOURCE_PRODUCTION, "hes_volume_ml"),
_f("bloodType", "血型", DEPT_PRODUCTION, SOURCE_PRODUCTION, "blood_type"),
_f("infectiousFourCard", "传染四项检测(检测卡)", DEPT_QC, SOURCE_PRODUCTION, "infectious_four_test_result"),
_f("infectiousFourExternal", "传染四项检测(外检)", DEPT_QC, SOURCE_EXTRA),
_f("preEnrichTnc", "富集前有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("enrichedBloodWeightG", "富集后血量/g", DEPT_PRODUCTION, SOURCE_PRODUCTION, "enriched_blood_weight_g"),
_f("postEnrichTnc", "富集后有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("cryoVolumeMl", "冻存液用量/ml", DEPT_PRODUCTION, SOURCE_PRODUCTION, "cryopreservation_volume_ml"),
_f("cd34Count", "CD34+细胞计数/*10E4", DEPT_QC, SOURCE_EXTRA),
_f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"),
_f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"),
)
UBMC_FIELDS: Tuple[FieldDef, ...] = (
_f("sampleCode", "细胞编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"),
_f("motherName", "准妈妈姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"),
_f("labMaterialCode", "实验室物料编码", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("labStorageCode", "入库编码", DEPT_PRODUCTION, SOURCE_RECEPTION, "lab_storage_code"),
_f("storageStatus", "储存状态", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("discardReason", "废弃原因", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("receptionTime", "接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"),
_f("receiveTemperature", "样品温度到达/℃", DEPT_PRODUCTION, SOURCE_RECEPTION, "receive_temperature"),
_f("receiveCheck", "收样检查", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"),
_f("receptionRemark", "备注", DEPT_PRODUCTION, SOURCE_RECEPTION, "remark"),
_f("originalBloodVolumeMl", "原血体积/mL", DEPT_PRODUCTION, SOURCE_PRODUCTION, "original_blood_volume_ml"),
_f("nucleatedCellCount1e8", "有核细胞数/108", DEPT_PRODUCTION, SOURCE_PRODUCTION, "nucleated_cell_count_1e8"),
_f("preOpTnc", "操作前有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("infectiousFourCard", "传染四项检测(检测卡)", DEPT_QC, SOURCE_PRODUCTION, "infectious_four_test_result"),
_f("hbsag", "乙型肝炎病毒表面抗原(HBsAg)", DEPT_QC, SOURCE_EXTRA),
_f("antiHcv", "丙型肝炎病毒抗体(抗HCV)", DEPT_QC, SOURCE_EXTRA),
_f("antiTp", "梅毒螺旋体特异性抗体(抗TP)", DEPT_QC, SOURCE_EXTRA),
_f("antiHiv", "人类免疫缺陷病毒抗体(抗HIV-1/2)", DEPT_QC, SOURCE_EXTRA),
_f("cmvIgm", "巨细胞病毒IgM 抗体 (CMV-IgM)", DEPT_QC, SOURCE_EXTRA),
_f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"),
_f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"),
)
MSC_FIELDS: Tuple[FieldDef, ...] = (
_f("sampleCode", "实验室流转编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"),
_f("motherName", "母亲姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"),
_f("collectTime", "脐带采集时间", DEPT_MARKET, SOURCE_PROTOCOL, "sample_collect_time"),
_f("transportTempC", "运输温度/℃", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("receptionTime", "脐带接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"),
_f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"),
_f("inoculationAmount", "接种量", DEPT_PRODUCTION, SOURCE_PRODUCTION, "inoculation_amount"),
_f("storageStatus", "储存状态", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("cryoTime", "冻存时间", DEPT_PRODUCTION, SOURCE_PRODUCTION, "cryopreservation_time"),
_f("storageTotalTubes", "储存总量(管)", DEPT_PRODUCTION, SOURCE_PRODUCTION, "storage_total_tubes"),
_f("availableCellCount1e7", "可提供细胞数量/*10E7", DEPT_PRODUCTION, SOURCE_PRODUCTION, "available_cell_count_1e7"),
_f("endotoxinTest", "内毒素检测", DEPT_QC, SOURCE_EXTRA),
_f("bacteriaTest", "细菌检测", DEPT_QC, SOURCE_EXTRA),
_f("fungusTest", "真菌检测", DEPT_QC, SOURCE_EXTRA),
_f("mycoplasmaTest", "支原体检测", DEPT_QC, SOURCE_EXTRA),
_f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"),
_f("motherHbsag", "乙型肝炎病毒表面抗原(HBsAg)", DEPT_QC, SOURCE_EXTRA),
_f("motherHbvDna", "乙型肝炎病毒核酸(HBV DNA)", DEPT_QC, SOURCE_EXTRA),
_f("motherAntiHcv", "丙型肝炎病毒抗体(抗HCV)", DEPT_QC, SOURCE_EXTRA),
_f("motherHcvRna", "丙型肝炎病毒核酸(HCV RNA)", DEPT_QC, SOURCE_EXTRA),
_f("motherAntiTp", "梅毒螺旋体特异性抗体(抗TP)", DEPT_QC, SOURCE_EXTRA),
_f("motherCmvIgm", "巨细胞病毒lgM 抗体 (CMV lgM)", DEPT_QC, SOURCE_EXTRA),
_f("motherAntiHiv", "人类免疫缺陷病毒抗体(抗HIV-1/2)", DEPT_QC, SOURCE_EXTRA),
_f("motherHiv1Rna", "人类免疫缺陷病毒核酸(1型) (HIV-1RNA)", DEPT_QC, SOURCE_EXTRA),
_f("storageDateP2", "储存日期(P2冻存日期)", DEPT_PRODUCTION, SOURCE_EXTRA),
_f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"),
)
FIELDS_BY_DATASET: Dict[str, Tuple[FieldDef, ...]] = {
DATASET_HSC: HSC_FIELDS,
DATASET_UBMC: UBMC_FIELDS,
DATASET_MSC: MSC_FIELDS,
}
DATASET_LABELS = {
DATASET_HSC: "造血干细胞数据",
DATASET_UBMC: "单个核数据",
DATASET_MSC: "脐带间充质干细胞数据",
}
def fields_for(dataset: str) -> Tuple[FieldDef, ...]:
return FIELDS_BY_DATASET.get(dataset) or ()
def field_map(dataset: str) -> Dict[str, FieldDef]:
return {f.key: f for f in fields_for(dataset)}
def editable_depts_for_user(*, is_manager: bool, dept_flags: Dict[str, bool]) -> set:
if is_manager:
return set(DEPTS)
return {d for d, ok in dept_flags.items() if ok}
def resolve_dataset_by_type_name(name: Optional[str], sample_code: Optional[str] = None) -> Optional[str]:
text = (name or "").strip()
code = (sample_code or "").strip().upper()
if "单个核" in text or "脐血单个核" in text or code.startswith("UBMC"):
return DATASET_UBMC
if "间充质" in text or code.startswith("BC") or code.startswith("UC"):
return DATASET_MSC
if "造血" in text or code.startswith("B"):
return DATASET_HSC
return None
def schema_payload(dataset: str) -> List[Dict[str, str]]:
return [
{"key": f.key, "label": f.label, "dept": f.dept, "source": f.source}
for f in fields_for(dataset)
]
+134 -1
View File
@@ -2,18 +2,78 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Dict, Optional from typing import Any, Dict, Iterable, List, Optional, Set
SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞" SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞" SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc" SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
SAMPLE_TYPE_KEY_UC_MSC = "uc_msc" SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
ACL_SCOPE_QC = "qc"
ACL_SCOPE_PRODUCTION = "production"
ACL_SCOPES = frozenset({ACL_SCOPE_QC, ACL_SCOPE_PRODUCTION})
ACL_CONFIG_MENU = "cell-storage:sample-info:storage-cert-qc-acl"
_NAME_TO_KEY = { _NAME_TO_KEY = {
SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC, SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC, SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
} }
# 与 web-admin STORAGE_CERT_QC_GROUPS 对齐的合法 payload 键(两类型并集)。
ALL_STORAGE_CERT_QC_FIELD_KEYS = frozenset(
{
"collection_time",
"transport_temp_c",
"receive_time",
"cryo_volume_ml",
"tnc_1e8",
"cd34_count_1e6",
"cd34_cd45dim_1e5",
"viability_pct",
"bacteria_test",
"hbsag",
"anti_hcv",
"anti_tp",
"anti_hiv",
"cmv_igm",
"mother_hbsag",
"mother_hbv_dna",
"mother_anti_hcv",
"mother_hcv_rna",
"mother_anti_tp",
"mother_cmv_igm",
"mother_anti_hiv",
"mother_hiv1_rna",
"hla_typing",
"storage_date",
"storage_years",
"result_comment",
"report_issue_date",
"storage_tube_count",
"available_cell_type",
"available_cell_count_1e7",
"available_viability_pct",
"endotoxin_test",
"fungus_test",
"mycoplasma_test",
"available_cell_morphology",
"available_immunophenotype",
"result_judgment",
"storage_date_p2",
"freeze_method",
"storage_temp",
}
)
# 脐带间充质储存证不收录母亲血液三项核酸检测。
UC_MSC_EXCLUDED_PAYLOAD_KEYS = frozenset(
{
"mother_hbv_dna",
"mother_hcv_rna",
"mother_hiv1_rna",
}
)
def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]: def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
name = (sample_type_name or "").strip() name = (sample_type_name or "").strip()
@@ -24,6 +84,13 @@ def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
return resolve_sample_type_key(sample_type_name) is not None return resolve_sample_type_key(sample_type_name) is not None
def legal_field_keys_for_type(sample_type_key: Optional[str]) -> Set[str]:
keys = set(ALL_STORAGE_CERT_QC_FIELD_KEYS)
if sample_type_key == SAMPLE_TYPE_KEY_UC_MSC:
keys -= UC_MSC_EXCLUDED_PAYLOAD_KEYS
return keys
def normalize_payload(payload: Any) -> Dict[str, Any]: def normalize_payload(payload: Any) -> Dict[str, Any]:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise ValueError("payload 必须为对象") raise ValueError("payload 必须为对象")
@@ -38,6 +105,16 @@ def normalize_payload(payload: Any) -> Dict[str, Any]:
return out return out
def sanitize_payload_for_type(
payload: Dict[str, Any],
sample_type_key: Optional[str],
) -> Dict[str, Any]:
"""按样本类型去掉不应展示/落库的键。"""
if sample_type_key != SAMPLE_TYPE_KEY_UC_MSC:
return payload
return {key: value for key, value in payload.items() if key not in UC_MSC_EXCLUDED_PAYLOAD_KEYS}
def dumps_payload(payload: Dict[str, Any]) -> str: def dumps_payload(payload: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
@@ -54,6 +131,62 @@ def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
return data return data
def dumps_field_keys(keys: Iterable[str]) -> str:
cleaned = sorted({str(k).strip() for k in keys if str(k).strip()})
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
def loads_field_keys(raw: Optional[str]) -> List[str]:
if not raw:
return []
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
out: List[str] = []
seen = set()
for item in data:
key = str(item or "").strip()
if not key or key in seen:
continue
seen.add(key)
out.append(key)
return out
def _payload_value_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def merge_payload_with_acl(
*,
existing: Dict[str, Any],
incoming: Dict[str, Any],
editable_keys: Iterable[str],
sample_type_key: Optional[str],
) -> Dict[str, Any]:
"""仅合并有权字段;无权字段若相对原值变化则拒绝。"""
allowed = {str(k).strip() for k in editable_keys if str(k).strip()}
if not allowed:
raise PermissionError("无字段编辑权限")
clean_incoming = sanitize_payload_for_type(normalize_payload(incoming), sample_type_key)
base = sanitize_payload_for_type(dict(existing or {}), sample_type_key)
for key, value in clean_incoming.items():
if key in allowed:
continue
if _payload_value_text(base.get(key)) != _payload_value_text(value):
raise PermissionError(f"无权修改字段: {key}")
merged = dict(base)
for key in allowed:
if key in clean_incoming:
merged[key] = clean_incoming[key]
return sanitize_payload_for_type(merged, sample_type_key)
def empty_detail( def empty_detail(
*, *,
sample_code: str, sample_code: str,
@@ -168,6 +168,11 @@ _HSC_SEROLOGY_RULES = (
) )
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_")) _MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
# 脐带间充质不预填母亲血液三项核酸。
_UC_MSC_EXCLUDED_MOTHER_KEYS = frozenset({"mother_hbv_dna", "mother_hcv_rna", "mother_hiv1_rna"})
_MOTHER_SEROLOGY_RULES_UC_MSC = tuple(
rule for rule in _MOTHER_SEROLOGY_RULES if str(rule[0]) not in _UC_MSC_EXCLUDED_MOTHER_KEYS
)
def _serology_from_tasks( def _serology_from_tasks(
@@ -278,9 +283,19 @@ def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
return out return out
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None: def _fill_shared_mother(
suggested: Dict[str, str],
tasks: Optional[List[Dict[str, Any]]],
*,
sample_type_key: str,
) -> None:
"""只补母亲血液空项。共享任务里的脐血不参与。""" """只补母亲血液空项。共享任务里的脐血不参与。"""
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES) rules = (
_MOTHER_SEROLOGY_RULES_UC_MSC
if sample_type_key == "uc_msc"
else _MOTHER_SEROLOGY_RULES
)
shared = _serology_from_tasks(tasks or [], rules)
for key, value in shared.items(): for key, value in shared.items():
suggested.setdefault(key, value) suggested.setdefault(key, value)
@@ -330,7 +345,7 @@ def build_suggested(
if count: if count:
suggested["available_cell_count_1e7"] = count suggested["available_cell_count_1e7"] = count
suggested.update(_process_qual_from_tasks(process_tasks or [])) suggested.update(_process_qual_from_tasks(process_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES)) suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES_UC_MSC))
suggested.setdefault("bacteria_test", "阴性") suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_judgment", "合格") suggested.setdefault("result_judgment", "合格")
if freeze: if freeze:
@@ -338,7 +353,7 @@ def build_suggested(
suggested.update(_FIXED_COMMON) suggested.update(_FIXED_COMMON)
suggested.update(_FIXED_UC_MSC) suggested.update(_FIXED_UC_MSC)
if sample_type_key in ("perinatal_hsc", "uc_msc"): if sample_type_key in ("perinatal_hsc", "uc_msc"):
_fill_shared_mother(suggested, shared_mother_tasks) _fill_shared_mother(suggested, shared_mother_tasks, sample_type_key=sample_type_key)
return {key: value for key, value in suggested.items() if str(value).strip()} return {key: value for key, value in suggested.items() if str(value).strip()}
@@ -0,0 +1,324 @@
"""将会议 Excel 三张表导入实验室样本处理补充台账。
用法(工程/worktree 根或 backend 下):
ENV_FILE=.../backend/.env python backend/scripts/import_lab_sample_processing_xlsx.py --xlsx PATH
"""
from __future__ import annotations
import argparse
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
# 允许直接脚本运行
ROOT = Path(__file__).resolve().parents[2]
BACKEND = ROOT / "backend"
if str(BACKEND) not in sys.path:
sys.path.insert(0, str(BACKEND))
from sqlalchemy.orm import Session # noqa: E402
from app.core.config import settings # noqa: E402
from app.core.database import SessionLocal # noqa: E402
from app.services.lab_sample_processing import upsert_extra_payload # noqa: E402
from app.services.lab_sample_processing_fields import ( # noqa: E402
DATASET_HSC,
DATASET_MSC,
DATASET_UBMC,
fields_for,
)
NS = {
"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
SHEET_DATASET = {
"造血干细胞数据": DATASET_HSC,
"单个核数据": DATASET_UBMC,
"脐带间充质干细胞数据": DATASET_MSC,
}
LABEL_ALIASES = {
"细胞编码": "sampleCode",
"实验室流转编码": "sampleCode",
"准妈妈姓名": "motherName",
"母亲姓名": "motherName",
"采集时间": "collectTime",
"脐带采集时间": "collectTime",
"采集医院": "collectHospital",
"接收时间": "receptionTime",
"脐带接收时间": "receptionTime",
"样品温度到达/℃": "receiveTemperature",
"运输温度/℃": "transportTempC",
"运输温度\n/℃": "transportTempC",
"收样检查": "receiveCheck",
"是否有母血": "hasMaternalBlood",
"是否重采母血": "remeasureMaternalBlood",
"知情同意书或协议书": "hasConsent",
"脐血运输装箱清单": "hasPackingList",
"脐带血接收确认单": "hasReceiveConfirm",
"孕妇健康调查表": "hasHealthSurvey",
"标识清晰": "labelClear",
"接收登记人": "receptionHandler",
"原血重量/g": "originalBloodWeightG",
"HES用量/ml": "hesVolumeMl",
"血型": "bloodType",
"传染四项检测(检测卡)": "infectiousFourCard",
"传染四项检测(外检)": "infectiousFourExternal",
"富集前有核细胞数/109/L": "preEnrichTnc",
"富集后血量/g": "enrichedBloodWeightG",
"富集后有核细胞数/109/L": "postEnrichTnc",
"冻存液用量/ml": "cryoVolumeMl",
"CD34+细胞计数/*10E4": "cd34Count",
"操作人": "operatorName",
"实验室物料编码": "labMaterialCode",
"入库编码": "labStorageCode",
"储存状态": "storageStatus",
"废弃原因": "discardReason",
"原血体积/mL": "originalBloodVolumeMl",
"有核细胞数/108": "nucleatedCellCount1e8",
"操作前有核细胞数/109/L": "preOpTnc",
"乙型肝炎病毒表面抗原(HBsAg)": "hbsag",
"丙型肝炎病毒抗体(抗HCV)": "antiHcv",
"梅毒螺旋体特异性抗体(抗TP)": "antiTp",
"人类免疫缺陷病毒抗体(抗HIV-1/2)": "antiHiv",
"巨细胞病毒IgM 抗体 (CMV-IgM)": "cmvIgm",
"接种量": "inoculationAmount",
"冻存时间": "cryoTime",
"储存总量(管)": "storageTotalTubes",
"储存总量\n(管)": "storageTotalTubes",
"可提供细胞数量/*10E7": "availableCellCount1e7",
"内毒素检测": "endotoxinTest",
"细菌检测": "bacteriaTest",
"真菌检测": "fungusTest",
"支原体检测": "mycoplasmaTest",
"乙型肝炎病毒表面抗原(HBsAg):": "motherHbsag",
"乙型肝炎病毒核酸(HBV DNA)": "motherHbvDna",
"丙型肝炎病毒核酸(HCV RNA)": "motherHcvRna",
"巨细胞病毒lgM 抗体 (CMV lgM)": "motherCmvIgm",
"人类免疫缺陷病毒核酸(1型) (HIV-1RNA)": "motherHiv1Rna",
"储存日期\n(P2冻存日期)": "storageDateP2",
"储存日期(P2冻存日期)": "storageDateP2",
}
def _norm_label(s: str) -> str:
return re.sub(r"\s+", "", (s or "").replace("\r", "").strip())
def _col_row(ref: str):
m = re.match(r"([A-Z]+)(\d+)", ref)
col = m.group(1)
row = int(m.group(2))
n = 0
for ch in col:
n = n * 26 + ord(ch) - 64
return n, row
def read_sheet_matrix(z: zipfile.ZipFile, target: str):
ss = []
if "xl/sharedStrings.xml" in z.namelist():
root = ET.fromstring(z.read("xl/sharedStrings.xml"))
for si in root.findall("m:si", NS):
texts = [
t.text or ""
for t in si.iter("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t")
]
ss.append("".join(texts))
root = ET.fromstring(z.read(target))
rows = {}
for c in root.findall(".//m:sheetData/m:row/m:c", NS):
ref = c.attrib.get("r")
t = c.attrib.get("t")
v = c.find("m:v", NS)
if v is None or ref is None:
continue
val = v.text or ""
if t == "s":
try:
val = ss[int(val)]
except Exception:
pass
cn, rn = _col_row(ref)
rows.setdefault(rn, {})[cn] = val
if not rows:
return []
max_r = max(rows)
max_c = max(max(cols) for cols in rows.values())
matrix = []
for rn in range(1, max_r + 1):
line = [str(rows.get(rn, {}).get(c, "") or "").strip() for c in range(1, max_c + 1)]
matrix.append(line)
return matrix
def build_header_map(header_rows: list, dataset: str):
"""合并多行表头,按列映射到字段 key。"""
width = max(len(r) for r in header_rows)
merged = [""] * width
for r in header_rows:
for i in range(width):
cell = r[i] if i < len(r) else ""
if cell:
merged[i] = cell if not merged[i] else merged[i]
# MSC: 第二行填充分组子列
if len(header_rows) >= 2:
for i in range(width):
top = header_rows[0][i] if i < len(header_rows[0]) else ""
sub = header_rows[1][i] if i < len(header_rows[1]) else ""
if sub:
merged[i] = sub
elif top and not merged[i]:
merged[i] = top
label_to_key = {_norm_label(f.label): f.key for f in fields_for(dataset)}
for alias, key in LABEL_ALIASES.items():
label_to_key[_norm_label(alias)] = key
# 备注列:第一处 receptionRemark,第二处 processRemark
remark_keys = []
col_map = {}
for idx, label in enumerate(merged):
n = _norm_label(label)
if not n or n in {"序号"}:
continue
if n == "备注":
remark_keys.append(idx)
continue
# 母血相关与单项同名字段在 MSC 中优先 mother*
key = label_to_key.get(n)
if not key and n.endswith(":"):
key = label_to_key.get(n[:-1])
if key:
col_map[idx] = key
if remark_keys:
col_map[remark_keys[0]] = "receptionRemark"
if len(remark_keys) > 1:
col_map[remark_keys[1]] = "processRemark"
elif "processRemark" in {f.key for f in fields_for(dataset)} and dataset != DATASET_UBMC:
# 单备注时,造血第二业务备注也可能只有一列;保留 receptionRemark
pass
# UBMC 仅一列备注时落到 receptionRemark;有操作备注需求时同步 processRemark
return col_map, merged
def parse_workbook(path: Path):
out = {}
with zipfile.ZipFile(path) as z:
wb = ET.fromstring(z.read("xl/workbook.xml"))
sheets = []
for sh in wb.findall("m:sheets/m:sheet", NS):
sheets.append(
(
sh.attrib.get("name"),
sh.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"),
)
)
rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
rid_to_target = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels}
for name, rid in sheets:
dataset = SHEET_DATASET.get(name)
if not dataset:
continue
target = rid_to_target[rid]
if not target.startswith("xl/"):
target = "xl/" + target
matrix = read_sheet_matrix(z, target)
if not matrix:
continue
# MSC 两行表头;其他一行(UBMC 可能首列分组)
if dataset == DATASET_MSC:
header_rows = matrix[:2]
data_rows = matrix[2:]
else:
header_rows = matrix[:1]
data_rows = matrix[1:]
col_map, merged = build_header_map(header_rows, dataset)
records = []
for row in data_rows:
payload = {}
for idx, key in col_map.items():
val = row[idx] if idx < len(row) else ""
if val != "":
payload[key] = val
code = str(payload.get("sampleCode") or "").strip()
if not code:
continue
# UBMC 同名母血检测字段:Excel 用的是非 mother 前缀,已映射
# MSC 母血字段别名
if dataset == DATASET_MSC:
if "antiHcv" in payload and "motherAntiHcv" not in payload:
payload["motherAntiHcv"] = payload.pop("antiHcv")
if "antiTp" in payload and "motherAntiTp" not in payload:
payload["motherAntiTp"] = payload.pop("antiTp")
if "antiHiv" in payload and "motherAntiHiv" not in payload:
payload["motherAntiHiv"] = payload.pop("antiHiv")
if "hbsag" in payload and "motherHbsag" not in payload:
payload["motherHbsag"] = payload.pop("hbsag")
payload["sampleCode"] = code
records.append(payload)
out[dataset] = {"headers": merged, "records": records, "col_map": col_map}
return out
def import_all(db: Session, tenant_no: str, parsed: dict) -> dict:
summary = {}
for dataset, pack in parsed.items():
n = 0
for payload in pack["records"]:
upsert_extra_payload(
db,
tenant_no=tenant_no,
dataset=dataset,
sample_code=payload["sampleCode"],
payload=payload,
operator_id="excel-import",
replace=True,
)
n += 1
summary[dataset] = n
db.commit()
return summary
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--xlsx", required=True)
parser.add_argument("--tenant", default=None)
args = parser.parse_args()
xlsx = Path(args.xlsx)
if not xlsx.exists():
raise SystemExit(f"xlsx not found: {xlsx}")
parsed = parse_workbook(xlsx)
if not parsed:
raise SystemExit("no sheets parsed")
db = SessionLocal()
try:
tenant_no = args.tenant
if not tenant_no:
# 取库中任一有效租户
from app.models.models import SysUser
u = (
db.query(SysUser)
.filter(SysUser.del_flag == 0, SysUser.status == "1", SysUser.tenant_no.isnot(None))
.first()
)
tenant_no = (u.tenant_no if u else None) or "zkxw"
summary = import_all(db, tenant_no, parsed)
print("tenant", tenant_no)
for ds, n in summary.items():
print(f"imported {ds}={n}")
print("ok")
finally:
db.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,30 @@
"""恢复被误软删的 Excel 补充字段(真菌/内毒素/CD34 等)并同步旧台账。
用法:
ENV_FILE=.../backend/.env python backend/scripts/restore_lab_sample_processing_excel_extras.py
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
BACKEND = ROOT / "backend"
if str(BACKEND) not in sys.path:
sys.path.insert(0, str(BACKEND))
from app.core.database import SessionLocal # noqa: E402
from app.services.lab_sample_processing import restore_retired_excel_extras # noqa: E402
def main() -> None:
db = SessionLocal()
try:
result = restore_retired_excel_extras(db, tenant_no="zkxw")
print(result)
finally:
db.close()
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
"""实验室样本处理台账:时间格式与字段合并。"""
from __future__ import annotations
import unittest
from datetime import datetime
from app.services.lab_sample_process import (
format_cn_datetime,
infectious_label,
merge_row,
)
class LabSampleProcessServiceTests(unittest.TestCase):
def test_format_cn_datetime(self):
self.assertEqual(
format_cn_datetime(datetime(2026, 11, 12, 0, 0, 0)),
"2026年11月12日 00时00分00秒",
)
self.assertEqual(
format_cn_datetime("2026-11-12 08:30:01"),
"2026年11月12日 08时30分01秒",
)
self.assertEqual(format_cn_datetime(""), "")
def test_infectious_label(self):
self.assertEqual(infectious_label("zc"), "正常")
self.assertEqual(infectious_label("bzc"), "不正常")
def test_merge_row_system_first_and_override(self):
merged = merge_row(
tab_key="donation_hsc",
system_values={"receiveCheckText": "系统汇总", "motherName": "张三"},
supplement={"receiveCheckText": "补录文案", "labMaterialCode": "M001"},
)
self.assertEqual(merged["receiveCheckText"], "补录文案")
self.assertEqual(merged["labMaterialCode"], "M001")
self.assertEqual(merged["motherName"], "张三")
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
import unittest
from app.services.lab_sample_processing_fields import (
DATASET_HSC,
DATASET_MSC,
DATASET_UBMC,
DEPT_MARKET,
DEPT_PRODUCTION,
DEPT_QC,
editable_depts_for_user,
field_map,
resolve_dataset_by_type_name,
schema_payload,
)
class LabSampleProcessingFieldsTest(unittest.TestCase):
def test_resolve_dataset(self):
self.assertEqual(resolve_dataset_by_type_name("围产期造血干细胞", "B0000001"), DATASET_HSC)
self.assertEqual(resolve_dataset_by_type_name("脐带间充质干细胞", "BC0000003"), DATASET_MSC)
self.assertEqual(resolve_dataset_by_type_name("", "UBMC2025001"), DATASET_UBMC)
self.assertEqual(resolve_dataset_by_type_name("", "UC2026001"), DATASET_MSC)
def test_schema_not_empty(self):
self.assertGreater(len(schema_payload(DATASET_HSC)), 10)
self.assertGreater(len(schema_payload(DATASET_UBMC)), 10)
self.assertGreater(len(schema_payload(DATASET_MSC)), 10)
def test_dept_acl(self):
self.assertEqual(
editable_depts_for_user(
is_manager=True,
dept_flags={DEPT_MARKET: False, DEPT_PRODUCTION: False, DEPT_QC: False},
),
{DEPT_MARKET, DEPT_PRODUCTION, DEPT_QC},
)
self.assertEqual(
editable_depts_for_user(
is_manager=False,
dept_flags={DEPT_MARKET: False, DEPT_PRODUCTION: True, DEPT_QC: False},
),
{DEPT_PRODUCTION},
)
def test_hsc_market_and_qc_fields(self):
fmap = field_map(DATASET_HSC)
self.assertEqual(fmap["motherName"].dept, DEPT_MARKET)
self.assertEqual(fmap["cd34Count"].dept, DEPT_QC)
self.assertEqual(fmap["originalBloodWeightG"].dept, DEPT_PRODUCTION)
def test_source_fields_not_overridden_by_extra(self):
from datetime import datetime
from app.services.lab_sample_processing import _build_row
from types import SimpleNamespace
sample = SimpleNamespace(id="S1", sample_code="ZKZX34000865", collection_protocol_id="P1")
party = SimpleNamespace(mother_name="真实妈妈")
coll = SimpleNamespace(
sample_collect_time=None,
maternal_blood_collect_time=datetime(2026, 11, 4, 5, 20),
)
hospital = SimpleNamespace(hospital_name="真实医院")
protocol = SimpleNamespace(
consent_form_urls="protocol-consent/a",
health_survey_form_urls="",
)
reception = SimpleNamespace(
id="R1",
reception_time=None,
receive_temperature=None,
handler_name="接收人",
remark=None,
lab_storage_code="B0000103",
)
production = SimpleNamespace(
id="PR1",
original_blood_weight_g=72.08,
hes_volume_ml=None,
blood_type="B",
infectious_four_test_result="zc",
enriched_blood_weight_g=None,
cryopreservation_volume_ml=None,
handler_name="操作人",
remark=None,
original_blood_volume_ml=None,
nucleated_cell_count_1e8=None,
inoculation_amount=None,
cryopreservation_time=None,
storage_total_tubes=None,
available_cell_count_1e7=None,
)
row = _build_row(
dataset=DATASET_HSC,
sample=sample,
type_name="围产期造血干细胞",
party=party,
coll=coll,
hospital=hospital,
reception=reception,
production=production,
extra={
"motherName": "Excel妈妈",
"originalBloodWeightG": 1,
"cd34Count": "11.4",
},
protocol=protocol,
check_flags={
"健康调查表": True,
"样本信息正确,标识清晰": True,
"针路密闭": True,
"容器无破损": True,
},
)
self.assertEqual(row["sampleCode"], "B0000103")
self.assertEqual(row["protocolSampleCode"], "ZKZX34000865")
self.assertEqual(row["motherName"], "真实妈妈")
self.assertEqual(row["originalBloodWeightG"], 72.08)
self.assertEqual(row["bloodType"], "B")
self.assertEqual(row["cd34Count"], "11.4")
self.assertEqual(row["hasMaternalBlood"], "是")
self.assertEqual(row["hasConsent"], "是")
self.assertEqual(row["hasHealthSurvey"], "是")
self.assertEqual(row["labelClear"], "是")
self.assertEqual(row["infectiousFourCard"], "正常")
self.assertIn("针路密闭", row["receiveCheck"] or "")
self.assertFalse(row["fromExtraOnly"])
def test_infectious_code_roundtrip(self):
from app.services.lab_sample_processing import _infectious_code, _infectious_label
self.assertEqual(_infectious_label("zc"), "正常")
self.assertEqual(_infectious_label("bzc"), "不正常")
self.assertEqual(_infectious_code("正常"), "zc")
self.assertEqual(_infectious_code("不正常"), "bzc")
def test_extra_field_keys_include_fungus(self):
from app.services.lab_sample_processing import _extra_field_keys
keys = _extra_field_keys(DATASET_MSC)
self.assertIn("fungusTest", keys)
self.assertIn("endotoxinTest", keys)
self.assertIn("bacteriaTest", keys)
self.assertIn("mycoplasmaTest", keys)
def test_msc_donation_collect_and_transport_fallback(self):
from datetime import datetime
from app.services.lab_sample_processing import _build_row
from types import SimpleNamespace
sample = SimpleNamespace(id="D1", sample_code="KYJZ1", collection_protocol_id=None)
party = SimpleNamespace(mother_name="孙玲")
coll = SimpleNamespace(
sample_collect_time=datetime(2026, 10, 10, 10, 5),
maternal_blood_collect_time=None,
)
reception = SimpleNamespace(
id="R1",
reception_time=datetime(2026, 10, 10, 15, 40, 57),
receive_temperature="23.1",
handler_name="侯以婷",
remark=None,
lab_storage_code="UC2026006",
)
row = _build_row(
dataset=DATASET_MSC,
sample=sample,
type_name="脐带间充质干细胞",
party=party,
coll=coll,
hospital=None,
reception=reception,
production=None,
extra={},
)
self.assertEqual(row["sampleCode"], "UC2026006")
self.assertEqual(row["collectTime"], "2026-10-10 10:05:00")
self.assertEqual(row["transportTempC"], "23.1")
self.assertNotIn("导出自存", row["motherName"] or "")
def test_legacy_supplement_fills_empty_source_fields(self):
from app.services.lab_sample_processing import _build_row, _merge_extra_with_legacy
from types import SimpleNamespace
extra = _merge_extra_with_legacy(
{},
{"infectiousFourTestLabel": "正常", "labMaterialCode": "202611006"},
)
sample = SimpleNamespace(id="S1", sample_code="UBMC202611006", collection_protocol_id=None)
reception = SimpleNamespace(
id="R1",
reception_time=None,
receive_temperature=None,
handler_name=None,
remark=None,
lab_storage_code="UBMC202611006",
)
production = SimpleNamespace(
id="PR1",
original_blood_weight_g=None,
hes_volume_ml=None,
blood_type=None,
infectious_four_test_result=None,
enriched_blood_weight_g=None,
cryopreservation_volume_ml=None,
handler_name=None,
remark=None,
original_blood_volume_ml=None,
nucleated_cell_count_1e8=None,
inoculation_amount=None,
cryopreservation_time=None,
storage_total_tubes=None,
available_cell_count_1e7=None,
)
row = _build_row(
dataset=DATASET_UBMC,
sample=sample,
type_name="脐血单个核细胞",
party=None,
coll=None,
hospital=None,
reception=reception,
production=production,
extra=extra,
)
self.assertEqual(row["infectiousFourCard"], "正常")
self.assertEqual(row["labMaterialCode"], "202611006")
if __name__ == "__main__":
unittest.main()
+63 -3
View File
@@ -8,8 +8,10 @@ from app.services.sample_storage_cert_qc import (
SAMPLE_TYPE_KEY_PERINATAL_HSC, SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_KEY_UC_MSC, SAMPLE_TYPE_KEY_UC_MSC,
empty_detail, empty_detail,
merge_payload_with_acl,
normalize_payload, normalize_payload,
resolve_sample_type_key, resolve_sample_type_key,
sanitize_payload_for_type,
supports_storage_cert_qc, supports_storage_cert_qc,
) )
from app.services.sample_storage_cert_qc_prefill import ( from app.services.sample_storage_cert_qc_prefill import (
@@ -51,6 +53,34 @@ class TestNormalizePayload(unittest.TestCase):
self.assertNotIn(1, out) self.assertNotIn(1, out)
class TestSanitizePayloadForType(unittest.TestCase):
def test_uc_msc_drops_mother_nucleic_acids(self):
out = sanitize_payload_for_type(
{
"mother_hbsag": "阴性",
"mother_hbv_dna": "阴性",
"mother_hcv_rna": "阳性",
"mother_hiv1_rna": "阴性",
"mother_cmv_igm": "阴性",
},
SAMPLE_TYPE_KEY_UC_MSC,
)
self.assertEqual(out["mother_hbsag"], "阴性")
self.assertEqual(out["mother_cmv_igm"], "阴性")
self.assertNotIn("mother_hbv_dna", out)
self.assertNotIn("mother_hcv_rna", out)
self.assertNotIn("mother_hiv1_rna", out)
def test_perinatal_keeps_mother_nucleic_acids(self):
payload = {
"mother_hbv_dna": "阴性",
"mother_hcv_rna": "阴性",
"mother_hiv1_rna": "阴性",
}
out = sanitize_payload_for_type(payload, SAMPLE_TYPE_KEY_PERINATAL_HSC)
self.assertEqual(out, payload)
class TestEmptyDetail(unittest.TestCase): class TestEmptyDetail(unittest.TestCase):
def test_shape(self): def test_shape(self):
d = empty_detail( d = empty_detail(
@@ -302,13 +332,13 @@ class TestStorageCertPrefill(unittest.TestCase):
] ]
suggested = build_suggested(sample_type_key="uc_msc", shared_mother_tasks=shared) suggested = build_suggested(sample_type_key="uc_msc", shared_mother_tasks=shared)
self.assertEqual(suggested["mother_hbsag"], "阴性") self.assertEqual(suggested["mother_hbsag"], "阴性")
self.assertEqual(suggested["mother_hbv_dna"], "阴性") self.assertNotIn("mother_hbv_dna", suggested)
self.assertEqual(suggested["mother_anti_hcv"], "阴性") self.assertEqual(suggested["mother_anti_hcv"], "阴性")
self.assertEqual(suggested["mother_hcv_rna"], "阴性") self.assertNotIn("mother_hcv_rna", suggested)
self.assertEqual(suggested["mother_anti_tp"], "阴性") self.assertEqual(suggested["mother_anti_tp"], "阴性")
self.assertEqual(suggested["mother_cmv_igm"], "阴性") self.assertEqual(suggested["mother_cmv_igm"], "阴性")
self.assertEqual(suggested["mother_anti_hiv"], "阴性") self.assertEqual(suggested["mother_anti_hiv"], "阴性")
self.assertEqual(suggested["mother_hiv1_rna"], "阴性") self.assertNotIn("mother_hiv1_rna", suggested)
self.assertNotIn("hbsag", suggested) self.assertNotIn("hbsag", suggested)
def test_own_mother_blood_wins_and_shared_only_fills_gaps(self): def test_own_mother_blood_wins_and_shared_only_fills_gaps(self):
@@ -339,5 +369,35 @@ class TestStorageCertPrefill(unittest.TestCase):
self.assertNotIn("hbsag", merged) self.assertNotIn("hbsag", merged)
class TestMergePayloadWithAcl(unittest.TestCase):
def test_merges_only_editable_keys(self):
merged = merge_payload_with_acl(
existing={"receive_time": "a", "bacteria_test": "阴性"},
incoming={"receive_time": "b", "bacteria_test": "阴性"},
editable_keys=["receive_time"],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
self.assertEqual(merged["receive_time"], "b")
self.assertEqual(merged["bacteria_test"], "阴性")
def test_rejects_unauthorized_change(self):
with self.assertRaises(PermissionError):
merge_payload_with_acl(
existing={"bacteria_test": "阴性"},
incoming={"bacteria_test": "阳性"},
editable_keys=["receive_time"],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
def test_rejects_empty_editable_keys(self):
with self.assertRaises(PermissionError):
merge_payload_with_acl(
existing={},
incoming={"receive_time": "x"},
editable_keys=[],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -0,0 +1,101 @@
"""储存证质检字段 ACL:合法字段与 scope。"""
import unittest
from unittest.mock import MagicMock
from sqlalchemy.dialects import mysql
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.models.models import BizStorageCertQcAcl, BizStorageCertQcAclUser, SysUser
from app.services.sample_storage_cert_qc import (
ACL_SCOPE_PRODUCTION,
ACL_SCOPE_QC,
ALL_STORAGE_CERT_QC_FIELD_KEYS,
SAMPLE_TYPE_KEY_UC_MSC,
dumps_field_keys,
legal_field_keys_for_type,
loads_field_keys,
)
class TestAclFieldKeys(unittest.TestCase):
def test_uc_msc_excludes_mother_nucleic_acids(self):
keys = legal_field_keys_for_type(SAMPLE_TYPE_KEY_UC_MSC)
self.assertNotIn("mother_hbv_dna", keys)
self.assertIn("mother_hbsag", keys)
def test_roundtrip_field_keys(self):
raw = dumps_field_keys(["receive_time", "bacteria_test", "receive_time", ""])
self.assertEqual(loads_field_keys(raw), ["bacteria_test", "receive_time"])
def test_all_keys_cover_common_fields(self):
self.assertIn("receive_time", ALL_STORAGE_CERT_QC_FIELD_KEYS)
self.assertIn("mother_anti_hiv", ALL_STORAGE_CERT_QC_FIELD_KEYS)
def test_scopes(self):
self.assertEqual(ACL_SCOPE_QC, "qc")
self.assertEqual(ACL_SCOPE_PRODUCTION, "production")
class TestAclUserJoinCollation(unittest.TestCase):
def test_detail_user_join_uses_unicode_collate(self):
"""JOIN sys_user 时统一 utf8mb4_unicode_ci,避免 1267 mix of collations。"""
acl = MagicMock()
acl.id = "acl-1"
acl.field_keys = "[]"
acl.configured_flag = "1"
captured = {}
class _Query:
def __init__(self, *args, **kwargs):
pass
def filter(self, *args, **kwargs):
captured.setdefault("filters", []).extend(args)
return self
def first(self):
return acl
def outerjoin(self, *args, **kwargs):
captured["join_on"] = args[1] if len(args) > 1 else None
return self
def order_by(self, *args, **kwargs):
return self
def all(self):
return []
db = MagicMock()
db.query.side_effect = lambda *args, **kwargs: _Query()
detail = acl_svc.get_acl_detail(db, "t1", "qc")
self.assertEqual(detail["scope"], "qc")
join_on = captured.get("join_on")
self.assertIsNotNone(join_on)
sql = str(
join_on.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})
).lower()
self.assertIn("utf8mb4_unicode_ci", sql)
filter_sql = " ".join(
str(f.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})).lower()
for f in captured.get("filters", [])
)
self.assertIn("utf8mb4_unicode_ci", filter_sql)
self.assertIn("sys_user", SysUser.__tablename__)
self.assertIn("biz_storage_cert_qc_acl_user", BizStorageCertQcAclUser.__tablename__)
self.assertEqual(BizStorageCertQcAcl.__tablename__, "biz_storage_cert_qc_acl")
def test_eq_unicode_literal_only_collates_column(self):
from app.core.db_collation import eq_unicode
expr = eq_unicode(BizStorageCertQcAcl.tenant_no, "t1")
sql = str(
expr.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})
).lower()
self.assertIn("utf8mb4_unicode_ci", sql)
self.assertEqual(sql.count("utf8mb4_unicode_ci"), 1)
if __name__ == "__main__":
unittest.main()
+7 -4
View File
@@ -79,10 +79,13 @@
"evidence": "用户确认既有 Gitea/Drone Tag 打包已完成,本次不复查或修改流水线。" "evidence": "用户确认既有 Gitea/Drone Tag 打包已完成,本次不复查或修改流水线。"
}, },
"permissions": { "permissions": {
"push_task_branch": false, "push_task_branch": true,
"create_pr": false, "create_pr": true,
"write_issue": false, "write_issue": true,
"merge": false, "close_issue": true,
"merge": true,
"manage_claims": true,
"update_issue_labels": true,
"push_tag": false, "push_tag": false,
"deploy": false "deploy": false
}, },
@@ -0,0 +1,21 @@
-- 实验室样本处理 ACL 归一化(可重复执行)
-- 1) 撤销所有 lab:sample-processing* / menu:lab-sample-processing 角色菜单
-- 2) 仅按下列角色重授:
-- 市场编辑:市场专员/临床销售/销售助理/客服/采集专员
-- 生产编辑:生产专员/实验专员/细胞实验员/研发专员
-- 质检编辑:质检专员/质量安全员
-- 主管:生产主管/质量主管/采集主管/采集经理/研发主管/研发总监/总经理/副总经理/董事长/超级管理员
-- 只读:采集接受人
-- 说明:正式执行请用 Agent 脚本 tmp_normalize_lab_acl.py(含幂等 undelete/insert)。
-- 本文件作为规则记录;勿在未核对角色名时直接全量套用到其他环境。
UPDATE sys_role_menu rm
JOIN sys_menu m ON m.id = rm.menu_id
SET rm.del_flag = 1,
rm.update_by = 'normalize-acl',
rm.update_time = NOW()
WHERE rm.del_flag = 0
AND (
m.perms LIKE 'lab:sample-processing%'
OR m.perms = 'menu:lab-sample-processing'
);
@@ -0,0 +1,23 @@
# 恢复 Excel 补充字段(真菌等)
## 原因
`20261112_lab_sample_processing_retire_excel_demo.sql` 按「补充表 sample_code = 协议样本号」软删演示行。
会议 Excel 实际多用实验室入库编码(`B000*` / `BC*` / `UBMC*`),与协议号不一致,导致真实样本的真菌/内毒素/CD34 等补充字段被误删。
## 处理
运行:
```bash
ENV_FILE=backend/.env python backend/scripts/restore_lab_sample_processing_excel_extras.py
```
脚本会:
1. 找出 `update_by=retire-excel-demo` 且 `del_flag=1` 的补充行
2. 仅恢复仍能关联到当前有效协议/接收编码的行
3. 只写回 `SOURCE_EXTRA` 字段到有效补充表
4. 同步旧台账 `biz_lab_sample_process_supplement`
列表侧同时会从质检完成任务填空(内毒素/真菌/支原体/细菌及母血相关)。
@@ -0,0 +1,14 @@
-- 软删除实验室样本处理 Excel 演示补充行:
-- 仅删除在协议样本表中不存在对应 sample_code 的补充记录。
-- Excel 列结构仍由代码字段目录维护;真实列表只聚合协议/接收/生产源数据。
UPDATE biz_lab_sample_processing_extra e
LEFT JOIN biz_protocol_sample s
ON s.sample_code = e.sample_code
AND s.tenant_no = e.tenant_no
AND s.del_flag = 0
SET e.del_flag = 1,
e.update_by = 'retire-excel-demo',
e.update_time = NOW()
WHERE e.del_flag = 0
AND s.id IS NULL;
@@ -0,0 +1,14 @@
-- Issue #97:将储存证质检 ACL 表 collation 对齐库默认 utf8mb4_unicode_ci。
-- 修复与 sys_user 等表 JOIN 时 Illegal mix of collations。可重复执行。
ALTER TABLE biz_storage_cert_qc_acl
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE biz_storage_cert_qc_acl_user
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SELECT table_name, table_collation
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('biz_storage_cert_qc_acl', 'biz_storage_cert_qc_acl_user')
ORDER BY table_name;
@@ -0,0 +1,108 @@
-- Issue #93:储存证质检信息字段级 ACL(质检/生产)。MySQL 5.7,可重复执行。
-- 菜单按钮「储存证质检字段权限配置」控制弹窗 footer 的「质检权限」「生产权限」入口;
-- 本脚本会将该按钮授权给当前租户下全部有效角色。
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl (
id VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL COMMENT 'qc|production',
field_keys TEXT NOT NULL COMMENT '可编辑字段 key JSON 数组',
configured_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已正式配置 0否1是',
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_scope (tenant_no, scope, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl_user (
id VARCHAR(64) NOT NULL,
acl_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_acl (tenant_no, acl_id, del_flag),
KEY ix_tenant_user (tenant_no, user_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
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
)
SELECT
INSERT(INSERT(MD5(CONCAT('storage-cert-qc-acl:', parent.tenant_no, ':', parent.id)),
13, 1, '3'), 17, 1, '8'),
'储存证质检字段权限配置', parent.id, 'F', '', NULL,
'cell-storage:sample-info:storage-cert-qc-acl', 1, '1', '1', parent.tenant_no, 95,
'控制储存证质检弹窗中质检/生产字段与人员 ACL 配置入口',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS parent
WHERE parent.del_flag = 0 AND parent.status = '1'
AND parent.perms = 'cell-storage:sample-info:list'
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = parent.id
AND existing.tenant_no = parent.tenant_no
AND existing.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND existing.del_flag = 0
);
-- 给所有有效角色授予「质检权限 / 生产权限」配置按钮(同一菜单码)
INSERT INTO sys_role_menu (
id, role_id, menu_id, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('storage-cert-qc-acl-role:', r.tenant_no, ':', r.id, ':', m.id)),
13, 1, '3'), 17, 1, '8'),
r.id,
m.id,
r.tenant_no,
NULL,
'migration: grant storage-cert-qc-acl to all roles',
'migration',
'',
NOW(),
'migration',
NOW(),
0
FROM sys_role AS r
JOIN sys_menu AS m
ON m.tenant_no = r.tenant_no
AND m.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND m.del_flag = 0
AND m.status = '1'
WHERE r.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu AS existing
WHERE existing.role_id = r.id
AND existing.menu_id = m.id
AND existing.tenant_no = r.tenant_no
AND existing.del_flag = 0
);
SELECT tenant_no, parent_id, id, menu_name, perms, status
FROM sys_menu
WHERE perms = 'cell-storage:sample-info:storage-cert-qc-acl' AND del_flag = 0;
SELECT r.tenant_no, r.role_name, m.perms, rm.id AS role_menu_id
FROM sys_role_menu AS rm
JOIN sys_role AS r ON r.id = rm.role_id AND r.tenant_no = rm.tenant_no AND r.del_flag = 0
JOIN sys_menu AS m ON m.id = rm.menu_id AND m.tenant_no = rm.tenant_no AND m.del_flag = 0
WHERE m.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND rm.del_flag = 0
ORDER BY r.tenant_no, r.role_name;
@@ -0,0 +1,148 @@
-- Issue #98:实验室样本处理数据台账(补录表 + 质检管理菜单)
-- MySQL 5.7,可重复执行。
CREATE TABLE IF NOT EXISTS biz_lab_sample_process_supplement (
id VARCHAR(64) NOT NULL,
lab_storage_code VARCHAR(32) NOT NULL COMMENT '实验室入库编码(唯一业务键)',
reception_source VARCHAR(20) NOT NULL DEFAULT 'cell_storage' COMMENT 'cell_storage|research_donation',
sample_type_name VARCHAR(100) NULL COMMENT '样本类型名称快照',
payload TEXT NOT NULL COMMENT '补录字段 JSON',
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_lab_code (tenant_no, lab_storage_code, del_flag),
KEY ix_tenant_source_type (tenant_no, reception_source, sample_type_name, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 菜单:挂在「质检管理」下(以样本接收菜单同父节点为准)
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
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sample-process:', parent.tenant_no, ':', parent.id)),
13, 1, '3'), 17, 1, '8'),
'实验室样本处理数据', parent.parent_id, 'C',
'lab-sample-process',
'quality-inspection/lab-sample-process/index',
'biz:labSampleProcess:list',
1, '1', '1', parent.tenant_no, 35,
'质检管理-实验室样本处理数据台账',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS parent
WHERE parent.del_flag = 0 AND parent.status = '1'
AND parent.perms = 'biz:sampleReception:list'
AND parent.parent_id IS NOT NULL
AND parent.parent_id != ''
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.tenant_no = parent.tenant_no
AND existing.perms = 'biz:labSampleProcess:list'
AND existing.del_flag = 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
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sample-process-sup:', m.tenant_no, ':', m.id)),
13, 1, '3'), 17, 1, '8'),
'补录', m.id, 'F', '', NULL,
'biz:labSampleProcess:supplement',
1, '1', '1', m.tenant_no, 10,
'实验室样本处理数据-补录',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS m
WHERE m.del_flag = 0 AND m.status = '1'
AND m.perms = 'biz:labSampleProcess:list'
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = m.id
AND existing.tenant_no = m.tenant_no
AND existing.perms = 'biz:labSampleProcess:supplement'
AND existing.del_flag = 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
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sample-process-exp:', m.tenant_no, ':', m.id)),
13, 1, '3'), 17, 1, '8'),
'导出', m.id, 'F', '', NULL,
'biz:labSampleProcess:export',
1, '1', '1', m.tenant_no, 20,
'实验室样本处理数据-导出',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS m
WHERE m.del_flag = 0 AND m.status = '1'
AND m.perms = 'biz:labSampleProcess:list'
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = m.id
AND existing.tenant_no = m.tenant_no
AND existing.perms = 'biz:labSampleProcess:export'
AND existing.del_flag = 0
);
-- 授权给拥有样本接收列表权限的角色
INSERT INTO sys_role_menu (
id, role_id, menu_id, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sample-process-rm:', r.tenant_no, ':', r.id, ':', m.id)),
13, 1, '3'), 17, 1, '8'),
r.id,
m.id,
r.tenant_no,
NULL,
'migration: grant lab sample process from sample reception roles',
'migration',
'',
NOW(),
'migration',
NOW(),
0
FROM sys_role AS r
JOIN sys_role_menu AS src
ON src.role_id = r.id AND src.tenant_no = r.tenant_no AND src.del_flag = 0
JOIN sys_menu AS srcm
ON srcm.id = src.menu_id AND srcm.tenant_no = src.tenant_no AND srcm.del_flag = 0
AND srcm.perms = 'biz:sampleReception:list'
JOIN sys_menu AS m
ON m.tenant_no = r.tenant_no
AND m.perms IN (
'biz:labSampleProcess:list',
'biz:labSampleProcess:supplement',
'biz:labSampleProcess:export'
)
AND m.del_flag = 0
AND m.status = '1'
WHERE r.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu AS existing
WHERE existing.role_id = r.id
AND existing.menu_id = m.id
AND existing.tenant_no = r.tenant_no
AND existing.del_flag = 0
);
SELECT tenant_no, parent_id, id, menu_name, path, component, perms
FROM sys_menu
WHERE perms LIKE 'biz:labSampleProcess:%' AND del_flag = 0
ORDER BY tenant_no, order_num, perms;
@@ -0,0 +1,175 @@
-- Issue #102:实验室样本处理数据挂到市场/生产/质检,权限码分端
-- 1) 质检管理:调到「入库放行审核」下方
-- 2) 存储服务:挂到「缴费财务确认」下方(cellStorage:labSampleProcess:*)
-- 3) 生产管理:挂到「废弃物管理」下方(production:labSampleProcess:*;若废弃物菜单缺失则先补齐)
-- 4) 拥有 menu:market / menu:production / menu:quality 的角色,授予对应大菜单下入口
-- MySQL 5.7,可重复执行
-- ── 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
)
SELECT
INSERT(INSERT(MD5(CONCAT('prod-waste-output:', p.tenant_no, ':', p.id)), 13, 1, '3'), 17, 1, '8'),
'废弃物管理', p.id, 'C', 'waste-output',
'/production/waste-output/index', 'production:waste-output:list',
1, '1', '1', p.tenant_no,
COALESCE((SELECT MAX(c.order_num) FROM sys_menu c WHERE c.parent_id = p.id AND c.del_flag = 0), 0) + 1,
'issue#102 补齐生产废弃物管理以便台账排序',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
WHERE p.del_flag = 0 AND p.status = '1' AND p.menu_name = '生产管理'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.parent_id = p.id
AND e.perms = 'production:waste-output:list' AND e.del_flag = 0
);
-- ── 1) 质检:调到入库放行审核下方(可重复;不连带改动其他菜单 order)────────
UPDATE sys_menu lab
JOIN sys_menu release_m
ON release_m.parent_id = lab.parent_id
AND release_m.del_flag = 0
AND release_m.menu_name = '入库放行审核'
AND release_m.perms = 'quality:storageReleaseReview:list'
SET lab.order_num = release_m.order_num + 1,
lab.update_by = 'migration',
lab.update_time = NOW()
WHERE lab.del_flag = 0
AND lab.perms = 'biz:labSampleProcess:list'
AND lab.path = 'lab-sample-process'
AND (lab.order_num IS NULL OR lab.order_num <> release_m.order_num + 1);
-- ── 2) 存储服务:缴费财务确认下方 ───────────────────────────────────────────
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
)
SELECT
INSERT(INSERT(MD5(CONCAT('cell-lab-sample-process:', anchor.tenant_no, ':', anchor.id)), 13, 1, '3'), 17, 1, '8'),
'实验室样本处理数据', anchor.parent_id, 'C', 'lab-sample-process',
'quality-inspection/lab-sample-process/index', 'cellStorage:labSampleProcess:list',
1, '1', '1', anchor.tenant_no, anchor.order_num + 1,
'市场-存储服务-实验室样本处理数据',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu anchor
WHERE anchor.del_flag = 0 AND anchor.status = '1'
AND anchor.perms = 'biz:cellStoragePaymentConfirm:list'
AND anchor.menu_name = '缴费财务确认'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = anchor.tenant_no
AND e.parent_id = anchor.parent_id
AND e.perms = 'cellStorage:labSampleProcess:list'
AND e.del_flag = 0
);
-- ── 3) 生产管理:废弃物管理下方 ─────────────────────────────────────────────
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
)
SELECT
INSERT(INSERT(MD5(CONCAT('prod-lab-sample-process:', anchor.tenant_no, ':', anchor.id)), 13, 1, '3'), 17, 1, '8'),
'实验室样本处理数据', anchor.parent_id, 'C', 'lab-sample-process',
'quality-inspection/lab-sample-process/index', 'production:labSampleProcess:list',
1, '1', '1', anchor.tenant_no, anchor.order_num + 1,
'生产-生产管理-实验室样本处理数据',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu anchor
WHERE anchor.del_flag = 0 AND anchor.status = '1'
AND anchor.perms = 'production:waste-output:list'
AND anchor.menu_name = '废弃物管理'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = anchor.tenant_no
AND e.parent_id = anchor.parent_id
AND e.perms = 'production:labSampleProcess:list'
AND e.del_flag = 0
);
-- ── 4) 补录 / 导出按钮(三处列表菜单各挂一套)──────────────────────────────
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
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sp-btn:', m.tenant_no, ':', m.id, ':', btn.perms)), 13, 1, '3'), 17, 1, '8'),
btn.menu_name, m.id, 'F', '', NULL, btn.perms,
1, '1', '1', m.tenant_no, btn.order_num, btn.remark,
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu m
JOIN (
SELECT '补录' AS menu_name, 'cellStorage:labSampleProcess:supplement' AS perms, 10 AS order_num,
'存储服务-实验室样本处理数据-补录' AS remark, 'cellStorage:labSampleProcess:list' AS parent_perms
UNION ALL
SELECT '导出', 'cellStorage:labSampleProcess:export', 20,
'存储服务-实验室样本处理数据-导出', 'cellStorage:labSampleProcess:list'
UNION ALL
SELECT '补录', 'production:labSampleProcess:supplement', 10,
'生产-实验室样本处理数据-补录', 'production:labSampleProcess:list'
UNION ALL
SELECT '导出', 'production:labSampleProcess:export', 20,
'生产-实验室样本处理数据-导出', 'production:labSampleProcess:list'
) btn ON btn.parent_perms = m.perms
WHERE m.del_flag = 0 AND m.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = m.tenant_no AND e.parent_id = m.id
AND e.perms = btn.perms AND e.del_flag = 0
);
-- ── 5) 按顶级大菜单授权对应入口(含列表/补录/导出)─────────────────────────
INSERT INTO sys_role_menu (
id, role_id, menu_id, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('lab-sp-rm:', r.tenant_no, ':', r.id, ':', m.id)), 13, 1, '3'), 17, 1, '8'),
r.id, m.id, r.tenant_no, NULL,
CONCAT('issue#102 grant from ', map.top_perms),
'migration', '', NOW(), 'migration', NOW(), 0
FROM (
SELECT 'menu:market' AS top_perms, 'cellStorage:labSampleProcess:list' AS list_perms
UNION ALL SELECT 'menu:market', 'cellStorage:labSampleProcess:supplement'
UNION ALL SELECT 'menu:market', 'cellStorage:labSampleProcess:export'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:list'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:supplement'
UNION ALL SELECT 'menu:production', 'production:labSampleProcess:export'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:list'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:supplement'
UNION ALL SELECT 'menu:quality', 'biz:labSampleProcess:export'
) map
JOIN sys_role r ON r.del_flag = 0
JOIN sys_role_menu src
ON src.role_id = r.id AND src.tenant_no = r.tenant_no AND src.del_flag = 0
JOIN sys_menu top
ON top.id = src.menu_id AND top.tenant_no = src.tenant_no AND top.del_flag = 0
AND top.perms = map.top_perms
JOIN sys_menu m
ON m.tenant_no = r.tenant_no AND m.del_flag = 0 AND m.status = '1'
AND m.perms = map.list_perms
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_menu existing
WHERE existing.role_id = r.id
AND existing.menu_id = m.id
AND existing.tenant_no = r.tenant_no
AND existing.del_flag = 0
);
-- 核对
SELECT p.menu_name AS parent_name, c.menu_name, c.path, c.perms, c.order_num
FROM sys_menu c
JOIN sys_menu p ON p.id = c.parent_id
WHERE c.del_flag = 0
AND (
c.perms LIKE '%labSampleProcess%'
OR (c.menu_name IN ('缴费财务确认', '废弃物管理', '入库放行审核')
AND p.menu_name IN ('存储服务', '生产管理', '质检管理'))
)
ORDER BY p.menu_name, c.order_num, c.menu_name;
@@ -0,0 +1,12 @@
-- 纠正实验室样本处理部门 ACL:
-- 「采集接受人」仅挂在「质检专员」职位上,不应再授予市场字段编辑权,
-- 否则质检人员经职位角色并集会同时可改市场+质检字段。
UPDATE sys_role_menu rm
JOIN sys_role r ON r.id = rm.role_id AND r.del_flag = 0 AND r.role_name = '采集接受人'
JOIN sys_menu m ON m.id = rm.menu_id AND m.del_flag = 0
AND m.perms = 'lab:sample-processing:market:edit'
SET rm.del_flag = 1,
rm.update_by = 'migration',
rm.update_time = NOW()
WHERE rm.del_flag = 0;
@@ -0,0 +1,120 @@
-- 实验室样本处理数据:补充表 + 一级模块菜单/按钮权限(不自动授权角色)
-- MySQL 5.7,可重复执行
CREATE TABLE IF NOT EXISTS biz_lab_sample_processing_extra (
id VARCHAR(64) NOT NULL,
dataset VARCHAR(16) NOT NULL COMMENT 'hsc|ubmc|msc',
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
payload_json TEXT NULL COMMENT '补充字段 JSON',
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_lab_sample_extra_tenant_ds_code (tenant_no, dataset, sample_code, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 挂到与「质检」同一 Web 虚拟根下(复用 menu:quality 的 parent_id)
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
)
SELECT
MD5(CONCAT(quality.tenant_no, ':menu:lab-sample-processing')),
'实验室样本处理数据', quality.parent_id, 'M', '/lab-sample-processing', '/index/index',
'menu:lab-sample-processing', 1, '1', '1', quality.tenant_no,
COALESCE(quality.order_num, 50) + 1,
'20261104 会议:造血/单个核/间充质台账',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu quality
WHERE quality.perms = 'menu:quality' AND quality.del_flag = 0 AND quality.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = quality.tenant_no AND e.perms = 'menu:lab-sample-processing' AND e.del_flag = 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
)
SELECT
MD5(CONCAT(p.tenant_no, ':lab:sample-processing:list')),
'造血干细胞数据', p.id, 'C', 'lab-sample-processing/hsc',
'/lab-sample-processing/hsc/index', 'lab:sample-processing:list',
1, '1', '1', p.tenant_no, 10, '造血干细胞数据列表',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/hsc' AND e.del_flag = 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
)
SELECT
MD5(CONCAT(p.tenant_no, ':lab:sample-processing:ubmc')),
'单个核数据', p.id, 'C', 'lab-sample-processing/ubmc',
'/lab-sample-processing/ubmc/index', 'lab:sample-processing:list',
1, '1', '1', p.tenant_no, 20, '单个核数据列表',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/ubmc' AND e.del_flag = 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
)
SELECT
MD5(CONCAT(p.tenant_no, ':lab:sample-processing:msc')),
'脐带间充质干细胞数据', p.id, 'C', 'lab-sample-processing/msc',
'/lab-sample-processing/msc/index', 'lab:sample-processing:list',
1, '1', '1', p.tenant_no, 30, '脐带间充质干细胞数据列表',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/msc' AND e.del_flag = 0
);
-- 编辑按钮挂在列表菜单下(三页共用同一 list 权限父节点:造血页)
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
)
SELECT
MD5(CONCAT(p.tenant_no, ':', btn.perms)),
btn.menu_name, p.id, 'F', '', NULL, btn.perms,
1, '0', '1', p.tenant_no, btn.order_num, btn.remark,
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu p
JOIN (
SELECT '市场字段编辑' AS menu_name, 'lab:sample-processing:market:edit' AS perms, 1 AS order_num, '仅可编辑市场部门字段' AS remark
UNION ALL SELECT '生产字段编辑', 'lab:sample-processing:production:edit', 2, '仅可编辑生产部门字段'
UNION ALL SELECT '质检字段编辑', 'lab:sample-processing:qc:edit', 3, '仅可编辑质检部门字段'
UNION ALL SELECT '部门主管', 'lab:sample-processing:manager', 4, '可编辑全部部门字段'
) btn
WHERE p.perms = 'lab:sample-processing:list'
AND p.path = 'lab-sample-processing/hsc'
AND p.del_flag = 0 AND p.status = '1'
AND NOT EXISTS (
SELECT 1 FROM sys_menu e
WHERE e.tenant_no = p.tenant_no AND e.perms = btn.perms AND e.del_flag = 0
);
@@ -0,0 +1,50 @@
-- Issue #100:移除多余一级菜单「实验室样本处理数据」及其子菜单/按钮
-- 软删除 sys_menu 与对应 sys_role_menu;不影响质检管理下 lab-sample-process 台账
-- MySQL 5.7,可重复执行
-- 1) 角色菜单授权
UPDATE sys_role_menu rm
JOIN sys_menu m ON m.id = rm.menu_id
SET rm.del_flag = 1,
rm.update_by = 'migration',
rm.update_time = NOW()
WHERE rm.del_flag = 0
AND (
m.perms IN (
'menu:lab-sample-processing',
'lab:sample-processing:list',
'lab:sample-processing:market:edit',
'lab:sample-processing:production:edit',
'lab:sample-processing:qc:edit',
'lab:sample-processing:manager'
)
OR m.path IN (
'/lab-sample-processing',
'lab-sample-processing/hsc',
'lab-sample-processing/ubmc',
'lab-sample-processing/msc'
)
);
-- 2) 按钮 / 子菜单 / 一级菜单(先叶子后父级,同批软删即可)
UPDATE sys_menu
SET del_flag = 1,
update_by = 'migration',
update_time = NOW()
WHERE del_flag = 0
AND (
perms IN (
'menu:lab-sample-processing',
'lab:sample-processing:list',
'lab:sample-processing:market:edit',
'lab:sample-processing:production:edit',
'lab:sample-processing:qc:edit',
'lab:sample-processing:manager'
)
OR path IN (
'/lab-sample-processing',
'lab-sample-processing/hsc',
'lab-sample-processing/ubmc',
'lab-sample-processing/msc'
)
);
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-12
@@ -0,0 +1,23 @@
## Context
附件三张表字段覆盖市场采集信息、实验室接收/处理与质检结果。系统内对应源主要为:
- 市场:`biz_protocol_party_customer`、`biz_protocol_collection_info`、`biz_collection_hospital`、`biz_protocol_sample`
- 生产/接收:`biz_sample_reception`、`biz_sample_production_reception`
- 质检:生产接收传染四项、储存证质检 payload、检查项结果;缺口进补充表
## Decisions
1. **数据集**:`hsc`(造血)、`ubmc`(单个核)、`msc`(脐带间充质),以样本类型名 / 编码前缀识别。
2. **权限码**:
- 列表:`lab:sample-processing:list`(三子页共用读)
- 部门编辑:`lab:sample-processing:market:edit` / `production:edit` / `qc:edit`
- 主管:`lab:sample-processing:manager`(可编辑全部部门字段)
3. **读写**:列表聚合只读源表 + 补充 JSON;更新时按字段 `source` 回写源表或补充表,服务端强制部门 ACL。
4. **UI**:P01 列表 + 筛选 + 导出;行内编辑弹窗按部门分区,无权字段只读。
5. **Figma**:MCP 授权不可用,按现有 Fresh Mint 列表页实现;合入后用户做视觉验收。
## Risks
- 历史 Excel 手工列与系统字段不完全一一对应,未映射列落补充表,避免阻断上线。
- 菜单种子不自动授权角色,需管理员分配。
@@ -0,0 +1,27 @@
## Why
2026-11-04 需求会议要求按线上三张实验室样本处理表(造血干细胞 / 单个核 / 脐带间充质)在系统中单独建模块,按市场、生产、质检拆分可编辑字段,支持筛选与导出。现有业务数据分散在协议、样本接收、生产接收与质检等表,缺少统一台账视图。
## What Changes
- 新增 Web 一级模块「实验室样本处理数据」,下挂三个子模块对应三张表。
- 按样本编码从现有业务表聚合展示全字段;部门员工可读全部、只写本部门字段;主管可写全部部门字段。
- 支持关键词/时间筛选与 Excel 导出。
- 无法直接回写源表的字段写入补充表 `biz_lab_sample_processing_extra`。
## Capabilities
### New Capabilities
- `lab-sample-processing-data`: 实验室样本处理数据聚合台账、部门字段 ACL、筛选导出
### Modified Capabilities
- (无)
## Impact
- **backend**:聚合服务、API、补充表、菜单权限
- **web-admin**:三子模块列表页、编辑弹窗、导出
- **docs/sql**:结构 + 菜单种子
- **不改** mobile-h5 / mobile-oa-h5
@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: 三子模块台账
系统 SHALL 提供「实验室样本处理数据」模块,包含造血干细胞、单个核、脐带间充质三个子模块;每个子模块按样本展示该表全部约定字段。
#### Scenario: 按数据集查询
- **WHEN** 用户打开造血干细胞子模块并具备列表权限
- **THEN** 系统仅返回造血干细胞数据集样本行,并包含市场/生产/质检字段聚合结果
### Requirement: 部门字段编辑权限
系统 SHALL 允许具备列表权限的用户查看全部字段;普通员工仅可编辑本部门字段;具备主管权限的用户可编辑全部部门字段。服务端 MUST 拒绝无权字段写入。
#### Scenario: 生产员工改市场字段被拒
- **WHEN** 仅有生产编辑权限的用户提交市场部门字段变更
- **THEN** 系统拒绝该请求且不持久化无权字段
#### Scenario: 主管可改全部
- **WHEN** 具备主管权限的用户提交跨部门字段变更
- **THEN** 系统接受并回写对应数据源或补充表
### Requirement: 筛选与导出
系统 SHALL 支持按样本编码、姓名关键词及接收/采集时间范围筛选,并导出当前筛选结果为 Excel。
#### Scenario: 导出当前筛选
- **WHEN** 用户在筛选条件下点击导出
- **THEN** 下载的表格列与当前子模块字段目录一致,行集与列表筛选一致
@@ -0,0 +1,21 @@
## 1. 规格与数据
- [x] 1.1 建立 OpenSpec 变更与字段/权限设计
- [x] 1.2 定义三数据集字段目录与部门归属、回写源
## 2. 后端
- [x] 2.1 补充表模型与迁移 SQL、菜单种子
- [x] 2.2 聚合列表 / 详情 / 更新 / 导出 API 与部门 ACL
- [x] 2.3 单元测试:数据集识别、字段 ACL、无权拒绝
## 3. 前端
- [x] 3.1 共享列表页组件(筛选、编辑、导出)
- [x] 3.2 三子模块路由与菜单码
- [x] 3.3 API 客户端
## 4. 验证
- [x] 4.1 受影响单元测试
- [ ] 4.2 字段一致性/菜单说明回写工单(凭据就绪后)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11
@@ -0,0 +1,47 @@
## Context
储存证质检信息由 `biz_sample_storage_cert_qc.payload` JSON 保存,Web 共用 `storage-cert-qc-dialog`,API 以菜单码控制读写且保存为整单覆盖。业务现要求按「质检 / 生产」拆分字段编辑权,并可由管理员配置人员。
已确认:方案 A(租户级配置 + 按人授权);配置入口独立菜单码;未配置前兼容旧写权限;同人字段并集;修改端为 web-admin + backend。
## Goals / Non-Goals
**Goals:**
- 租户可配置质检、生产两套「字段 + 人员」ACL
- 前端按字段只读/可写;后端 merge 强制执行
- 三处入口行为一致;配置 UI 符合 Fresh Mint 弹窗规范
**Non-Goals:**
- 不按样本、不按工单单独配置 ACL
- 不引入角色绑定作为本期主路径(人员勾选)
- 不改造 mobile-h5 / mobile-oa-h5
- 不改变 payload 字段语义与预填逻辑(仅限制谁能改)
## Decisions
1. **存储**:主表 `biz_storage_cert_qc_acl`(`tenant_no`+`scope` 唯一,`field_keys` JSON,`configured_flag`)+ 子表 `biz_storage_cert_qc_acl_user`(`acl_id`+`user_id`,软删同步)。相对单 JSON 大包,子表便于人员校验与审计扩展。
2. **权限解析**:用户可编辑字段 = 其所属各已配置 scope 的 field_keys 并集;严格模式在「任一 scope 的 configured_flag=1」时启用;否则有原 WRITE_MENUS 即可编辑全部合法字段。
3. **写路径**:**BREAKING 行为**:`PUT` 从整单覆盖改为服务端 merge——仅更新 `editableFieldKeys` 内键;无任何可写字段拒绝保存;提交中含无权键且值相对库内有变化则 403。
4. **配置权限**:独立菜单码(建议 `cell-storage:sample-info:storage-cert-qc-acl`);与业务填写菜单分离。按钮仅在 `canConfigureAcl=true` 时展示。
5. **字段候选**:前后端共用类型字段清单(与 `STORAGE_CERT_QC_GROUPS` / 后端 type keys 对齐);配置 UI 展示两样本类型字段并集,非法 key 拒绝保存。
6. **UI**:主弹窗 footer 左侧配置按钮、右侧取消/确定;配置子弹两个 Tab(字段勾选 / 人员多选)。先 Figma 确认再编码。
## Risks / Trade-offs
- [未配置→已配置切换瞬间全员变严] → 上线说明;兼容期默认未配置仍可写;配置保存前可预览影响(可选,本期不做强制)。
- [人员名单维护成本] → 接受方案 A;后续可加角色扩展,不阻塞本期。
- [整单覆盖客户端旧代码] → 后端 merge 兼容旧客户端:无权字段被忽略或在变更时 403;推荐 403 以便发现错误集成。
- [超级管理员] → 不默认绕过 ACL;仅配置菜单控制谁能改 ACL。若需超管旁路,另开需求。
## Migration Plan
1. 执行 SQL 建表;部署后端(读接口先返回兼容态 editableFieldKeys=全部或空+aclStrict=false)。
2. 部署前端(无配置权限时 UI 与现网接近)。
3. 管理员配置质检/生产后该租户进入严格模式。
4. 回滚:保留表数据;回退代码后配置表闲置;严格模式逻辑随代码回退消失。
## Open Questions
- 无(关键项已在对话确认:租户级、独立配置菜单、兼容期、字段并集、先 Figma)。
@@ -0,0 +1,28 @@
## Why
「填写储存证质检信息」弹窗目前只要具备菜单写权限即可改全部字段,无法按质检 / 生产职责拆分。业务需要:质检与生产各自配置可编辑字段与人员,范围外用户仅可查看,且服务端必须强制校验。
## What Changes
- 新增租户级储存证质检字段 ACL:两个 scope(`qc` / `production`),各自保存可编辑字段列表与可编辑人员。
- 详情接口返回当前用户 `editableFieldKeys` 等权限上下文;保存改为服务端按权限 merge,拒绝无权字段篡改。
- 主弹窗 footer 左侧增加「质检权限」「生产权限」入口(仅配置权限可见);配置子弹含「勾选字段」「配置人员」两个 Tab。
- 未对任一 scope 保存过配置时,兼容现有菜单写权限(可改全部);任一 scope 首次配置后该租户进入严格 ACL。
- **不改**小程序 / mobile-oa-h5;三处 Web 入口(客户服务、质检工单、生产工单)共用同一弹窗组件一并生效。
## Capabilities
### New Capabilities
- `storage-cert-qc-field-acl`: 储存证质检信息字段级编辑权限(配置、解析、读写强制)
### Modified Capabilities
- (无;`openspec/specs/` 尚未建立长期基线中的对应能力)
## Impact
- **backend**:新表与 CRUD/API;`sample_storage_cert_qc` 读/写路径;菜单码与鉴权
- **web-admin**:`storage-cert-qc-dialog`、ACL 配置子弹、API 客户端
- **docs/sql**:结构迁移脚本
- **权限**:租户隔离;配置入口独立菜单码;写入不信任客户端字段权限声明
@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: 租户级质检与生产字段 ACL 配置
系统 SHALL 按租户维护储存证质检信息的两套编辑权限配置,scope 分别为 `qc`(质检)与 `production`(生产)。每套配置 SHALL 包含可编辑字段 key 列表与可编辑用户列表。字段 key MUST 属于该业务已定义的合法 payload 键集合。
#### Scenario: 管理员保存质检权限
- **WHEN** 具备 ACL 配置菜单权限的用户保存 scope=`qc` 的字段列表与人员列表
- **THEN** 系统持久化该租户的质检 ACL,并将该 scope 标记为已配置
#### Scenario: 非法字段被拒绝
- **WHEN** 配置请求包含不在合法 payload 键集合中的 field key
- **THEN** 系统拒绝保存并返回错误,不部分写入
### Requirement: 按当前用户解析可编辑字段
系统在返回储存证质检详情时 SHALL 附带当前用户可编辑字段集合。可编辑字段 MUST 为用户所属各 scope 字段列表的并集。未出现在任一 ACL 人员名单中的用户,在严格模式下 MUST 得到空的可编辑字段集合(仍可只读查看详情)。
#### Scenario: 同属两 scope 取并集
- **WHEN** 用户同时在质检与生产人员名单中,且两 scope 勾选了不同字段
- **THEN** 详情中的可编辑字段为两 scope 字段的并集
#### Scenario: 未授权用户只读
- **WHEN** 租户已进入严格 ACL,且当前用户不在任一 scope 人员名单中
- **THEN** 可编辑字段为空,用户可查看已有数据但不能修改
### Requirement: 未配置前兼容菜单写权限
当租户的质检与生产 scope 均未被标记为已配置时,系统 SHALL 保持兼容行为:只要用户具备既有储存证质检写菜单权限,即可编辑全部合法字段。当任一 scope 被标记为已配置后,该租户 MUST 进入严格 ACL,不再仅凭菜单写权限获得全字段编辑权。
#### Scenario: 全新租户未配置
- **WHEN** 租户两 scope 均未配置,且用户具备既有写菜单权限
- **THEN** 详情返回全部合法字段为可编辑,保存可更新这些字段
#### Scenario: 首次配置后收紧
- **WHEN** 管理员首次成功保存任一 scope 的 ACL
- **THEN** 之后该租户按严格 ACL 解析可编辑字段与保存权限
### Requirement: 保存时服务端强制 merge
储存证质检保存接口 MUST 仅更新当前用户有权编辑的字段;无权字段 MUST 保留库中原值。若请求试图修改无权字段的值,系统 MUST 拒绝该请求。当用户没有任何可编辑字段时,系统 MUST 拒绝保存。
#### Scenario: 部分字段可写
- **WHEN** 用户仅对字段集合 A 有编辑权并提交包含 A 与其他字段的 payload
- **THEN** 仅 A 中相对原值有权限的变更被写入,其他键保持原值;若其他键相对原值发生变化则整单拒绝
#### Scenario: 无可写字段
- **WHEN** 当前用户可编辑字段为空并调用保存接口
- **THEN** 系统拒绝保存
### Requirement: 配置入口与主弹窗交互
Web 管理端储存证质检弹窗在用户具备 ACL 配置权限时,SHALL 在 footer 左侧提供「质检权限」「生产权限」入口。配置界面 SHALL 提供两个 Tab:勾选可编辑字段、配置可编辑人员。不具备配置权限的用户 MUST 看不到上述入口。不具备字段编辑权的用户打开业务弹窗时 MUST 以只读方式展示字段,且不得出现可提交业务保存的主按钮(或等价禁用)。
#### Scenario: 管理员可见配置按钮
- **WHEN** 具备 ACL 配置菜单权限的用户打开填写弹窗
- **THEN** footer 左侧显示质检权限与生产权限按钮
#### Scenario: 普通填写人不可见配置
- **WHEN** 不具备 ACL 配置菜单权限的用户打开填写弹窗
- **THEN** 不显示质检权限与生产权限按钮
#### Scenario: 三处入口一致
- **WHEN** 用户从客户服务、质检工单或生产工单打开同一储存证质检弹窗
- **THEN** 字段可编辑性与配置入口可见性均按同一 ACL 与菜单规则生效
@@ -0,0 +1,26 @@
## 1. 数据与后端 ACL
- [x] 1.1 新增 `docs/sql/` 迁移:`biz_storage_cert_qc_acl`、`biz_storage_cert_qc_acl_user`(租户、软删、标准审计字段)
- [x] 1.2 新增 ORM / CRUD:按 tenant+scope 读写字段列表与人员;保存时校验合法 field key、同步子行
- [x] 1.3 实现当前用户可编辑字段解析(兼容模式 vs 严格模式、两 scope 并集)
- [x] 1.4 扩展 GET 详情:返回 `editableFieldKeys`、`canConfigureAcl`、`aclStrict`
- [x] 1.5 改造 PUT 保存为服务端 merge,无权字段变更拒绝,无可写字段拒绝
- [x] 1.6 新增 ACL GET/PUT API,鉴权独立配置菜单码
- [x] 1.7 补充/调整 `backend/tests/test_sample_storage_cert_qc.py`(及 ACL 用例)覆盖兼容、严格、并集、拒绝篡改
## 2. Web 主弹窗与配置弹窗
- [x] 2.1 按已确认 Figma 原型调整 `storage-cert-qc-dialog` footer(左侧配置按钮、右侧取消/确定)与字段只读态
- [x] 2.2 新增 ACL 配置弹窗(质检/生产):Tab1 分组勾选字段,Tab2 选人,保存调 ACL API
- [x] 2.3 接入 API 客户端与菜单码;无配置权限不展示入口;无可写字段隐藏业务确定
- [x] 2.4 核对客户服务 / 质检工单 / 生产工单三处入口共用行为
## 3. 权限种子与文档回写
- [x] 3.1 增加配置菜单码种子/SQL(命名与路由约定一致),说明仅管理员授予
- [ ] 3.2 工单回写:OpenSpec 路径、Figma 链接、验收要点;原型确认结论
## 4. 验证
- [x] 4.1 在任务 worktree 运行受影响后端单元测试并记录结果
- [x] 4.2 源码级对照已确认原型(footer 布局、双 Tab、按钮主次、只读态)
+55
View File
@@ -0,0 +1,55 @@
import request from '@/utils/http'
export type LabSampleProcessTabKey =
| 'cell_hsc'
| 'donation_hsc'
| 'cell_msc'
| 'donation_msc'
export interface LabSampleProcessListParams {
tabKey: LabSampleProcessTabKey
page?: number
pageSize?: number
labStorageCode?: string
motherName?: string
createTimeStart?: string
createTimeEnd?: string
}
export function fetchLabSampleProcessList(params: LabSampleProcessListParams) {
return request.get<{
list: Record<string, unknown>[]
total: number
page: number
pageSize: number
tabKey: string
supplementKeys: string[]
}>({
url: '/web/admin/lab-sample-process/list',
params
})
}
export function fetchLabSampleProcessSupplement(labStorageCode: string) {
return request.get<{
labStorageCode?: string
payload?: Record<string, string>
remark?: string
}>({
url: '/web/admin/lab-sample-process/supplement',
params: { labStorageCode }
})
}
export function saveLabSampleProcessSupplement(body: {
labStorageCode: string
receptionSource: string
sampleTypeName: string
payload: Record<string, string>
remark?: string
}) {
return request.put({
url: '/web/admin/lab-sample-process/supplement',
data: body
})
}
@@ -0,0 +1,74 @@
import request from '@/utils/http'
export type LabSampleDataset = 'hsc' | 'ubmc' | 'msc'
export interface LabSampleFieldMeta {
key: string
label: string
dept: 'market' | 'production' | 'qc'
source: string
}
export interface LabSampleAcl {
canList?: boolean
isManager?: boolean
editableDepts?: string[]
deptFlags?: Record<string, boolean>
}
export function fetchLabSampleMeta(dataset: LabSampleDataset) {
return request.get<{
dataset: LabSampleDataset
label: string
fields: LabSampleFieldMeta[]
acl: LabSampleAcl
}>({
url: `/web/admin/lab-sample-processing/meta/${dataset}`
})
}
export function fetchLabSampleList(
dataset: LabSampleDataset,
params: {
keyword?: string
dateFrom?: string
dateTo?: string
page?: number
pageSize?: number
}
) {
return request.get<{
list: Record<string, any>[]
total: number
page: number
pageSize: number
fields: LabSampleFieldMeta[]
acl: LabSampleAcl
}>({
url: `/web/admin/lab-sample-processing/${dataset}/list`,
params
})
}
export function updateLabSampleRow(
dataset: LabSampleDataset,
sampleCode: string,
payload: Record<string, any>
) {
return request.put<Record<string, any>>({
url: `/web/admin/lab-sample-processing/${dataset}/${encodeURIComponent(sampleCode)}`,
data: { payload }
})
}
export function exportLabSampleUrl(
dataset: LabSampleDataset,
params: { keyword?: string; dateFrom?: string; dateTo?: string }
) {
const q = new URLSearchParams()
if (params.keyword) q.set('keyword', params.keyword)
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
if (params.dateTo) q.set('dateTo', params.dateTo)
const qs = q.toString()
return `/api/web/admin/lab-sample-processing/${dataset}/export${qs ? `?${qs}` : ''}`
}
@@ -12,6 +12,20 @@ export type SampleStorageCertQcDetail = {
filledFlag: boolean filledFlag: boolean
payload: Record<string, unknown> payload: Record<string, unknown>
prefill?: Record<string, unknown> prefill?: Record<string, unknown>
editableFieldKeys?: string[]
canConfigureAcl?: boolean
aclStrict?: boolean
}
export type StorageCertQcAclScope = 'qc' | 'production'
export type StorageCertQcAclDetail = {
scope: StorageCertQcAclScope
fieldKeys: string[]
userIds: string[]
users: Array<{ id: string; nickName: string; userName: string }>
configuredFlag: boolean
aclStrict: boolean
} }
export function fetchSampleStorageCertQc(sampleCode: string) { export function fetchSampleStorageCertQc(sampleCode: string) {
@@ -29,3 +43,19 @@ export function saveSampleStorageCertQc(
data data
}) })
} }
export function fetchSampleStorageCertQcAcl(scope: StorageCertQcAclScope) {
return request.get({
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`
})
}
export function saveSampleStorageCertQcAcl(
scope: StorageCertQcAclScope,
data: { fieldKeys: string[]; userIds: string[] }
) {
return request.put({
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`,
data
})
}
@@ -0,0 +1,308 @@
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="720px"
class="dialog-form-layout storage-cert-qc-acl-dialog"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
@update:model-value="emit('update:visible', $event)"
>
<div v-loading="loading" class="storage-cert-qc-acl-dialog__body">
<el-tabs v-model="activeTab">
<el-tab-pane label="勾选可编辑字段" name="fields">
<div class="field-toolbar">
<el-button @click="selectAllFields">
<el-icon><Select /></el-icon>
全选
</el-button>
<el-button @click="clearAllFields">
<el-icon><RefreshLeft /></el-icon>
取消全选
</el-button>
</div>
<div v-for="(group, gi) in fieldGroups" :key="gi" class="field-block">
<div class="field-block__title">{{ group.title }}</div>
<el-checkbox-group v-model="selectedKeys" class="field-check-grid">
<el-checkbox
v-for="field in group.fields"
:key="field.key"
:label="field.key"
class="field-check"
>
{{ field.label }}
</el-checkbox>
</el-checkbox-group>
</div>
</el-tab-pane>
<el-tab-pane label="配置人员" name="users">
<el-select
v-model="selectedUserIds"
multiple
filterable
remote
clearable
reserve-keyword
:remote-method="searchUsers"
:loading="userLoading"
placeholder="输入姓名或账号搜索"
style="width: 100%"
>
<el-option
v-for="u in userOptions"
:key="u.value"
:label="u.label"
:value="u.value"
/>
</el-select>
<div class="form-tip">仅名单内用户可修改本权限勾选的字段。</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, RefreshLeft, Select } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import {
fetchSampleStorageCertQcAcl,
saveSampleStorageCertQcAcl,
type StorageCertQcAclScope
} from '@/api/sample-storage-cert-qc'
import { STORAGE_CERT_QC_GROUPS } from '@/utils/storage-cert-qc-fields'
const props = defineProps<{
visible: boolean
scope: StorageCertQcAclScope
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'saved'): void
}>()
const loading = ref(false)
const saving = ref(false)
const userLoading = ref(false)
const activeTab = ref('fields')
const selectedKeys = ref<string[]>([])
const selectedUserIds = ref<string[]>([])
const userOptions = ref<Array<{ label: string; value: string }>>([])
const dialogTitle = computed(() => (props.scope === 'qc' ? '质检权限' : '生产权限'))
const fieldGroups = computed(() => {
const map = new Map<string, { title: string; fields: Array<{ key: string; label: string }> }>()
for (const groups of Object.values(STORAGE_CERT_QC_GROUPS)) {
for (const group of groups) {
const item = map.get(group.title) || { title: group.title, fields: [] }
const seen = new Set(item.fields.map((f) => f.key))
for (const field of group.fields) {
if (seen.has(field.key)) continue
seen.add(field.key)
item.fields.push({ key: field.key, label: field.label })
}
map.set(group.title, item)
}
}
return Array.from(map.values())
})
const allFieldKeys = computed(() => {
const keys: string[] = []
const seen = new Set<string>()
for (const group of fieldGroups.value) {
for (const field of group.fields) {
if (seen.has(field.key)) continue
seen.add(field.key)
keys.push(field.key)
}
}
return keys
})
function selectAllFields() {
selectedKeys.value = [...allFieldKeys.value]
}
function clearAllFields() {
selectedKeys.value = []
}
function formatUserOption(row: {
id?: string
nickName?: string
userName?: string
userNo?: string
}) {
const id = String(row.id || '')
if (!id) return null
const name = String(row.nickName || row.userName || row.userNo || id)
const account = String(row.userName || row.userNo || '')
return { value: id, label: account ? `${name}(${account})` : name }
}
function upsertUserOption(id: string, label: string) {
if (!id) return
if (userOptions.value.some((u) => u.value === id)) return
userOptions.value.push({ value: id, label: label || id })
}
async function searchUsers(keyword: string) {
userLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
status: '1',
keyword: (keyword || '').trim() || undefined
})
const list = (res as any)?.list || []
const next: Array<{ label: string; value: string }> = []
const seen = new Set<string>()
for (const row of list) {
const opt = formatUserOption(row)
if (!opt || seen.has(opt.value)) continue
seen.add(opt.value)
next.push(opt)
}
// 已选人员不在本页结果中时保留,避免标签丢失且不影响远程模糊检索展示
for (const id of selectedUserIds.value) {
if (seen.has(id)) continue
const keep = userOptions.value.find((u) => u.value === id)
if (keep) {
seen.add(id)
next.unshift(keep)
}
}
userOptions.value = next
} catch {
// 检索失败时仍保留已选项,避免清空已配置人员标签
userOptions.value = userOptions.value.filter((u) =>
selectedUserIds.value.includes(u.value)
)
} finally {
userLoading.value = false
}
}
async function loadDetail() {
loading.value = true
activeTab.value = 'fields'
try {
const detail = (await fetchSampleStorageCertQcAcl(props.scope)) as any
selectedKeys.value = Array.isArray(detail?.fieldKeys) ? [...detail.fieldKeys] : []
selectedUserIds.value = Array.isArray(detail?.userIds) ? [...detail.userIds] : []
userOptions.value = []
for (const u of detail?.users || []) {
const opt = formatUserOption({
id: u.id,
nickName: u.nickName,
userName: u.userName
})
if (opt) upsertUserOption(opt.value, opt.label)
}
// 预拉一批启用用户;真正按姓名/账号模糊检索在输入时由 remote-method 触发
await searchUsers('')
} catch {
selectedKeys.value = []
selectedUserIds.value = []
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await saveSampleStorageCertQcAcl(props.scope, {
fieldKeys: [...selectedKeys.value],
userIds: [...selectedUserIds.value]
})
ElMessage.success('保存成功')
emit('saved')
emit('update:visible', false)
} finally {
saving.value = false
}
}
watch(
() => [props.visible, props.scope] as const,
([vis]) => {
if (vis) loadDetail()
}
)
</script>
<style scoped lang="scss">
.storage-cert-qc-acl-dialog__body {
min-height: 280px;
}
.field-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.field-block {
margin-bottom: 14px;
}
.field-block__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary-dark-2, #059669);
}
.field-check-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px;
width: 100%;
}
.field-check {
display: flex;
align-items: flex-start;
margin-right: 0;
margin-bottom: 0;
height: auto;
white-space: normal;
:deep(.el-checkbox__label) {
white-space: normal;
line-height: 1.4;
}
}
@media (max-width: 560px) {
.field-check-grid {
grid-template-columns: 1fr;
}
}
.form-tip {
margin-top: 10px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
</style>
@@ -54,7 +54,7 @@
'form-col-span-3': field.span === 3 'form-col-span-3': field.span === 3
}" }"
> >
<template v-if="mode === 'view'"> <template v-if="mode === 'view' || !isFieldEditable(field.key)">
<div <div
class="readonly-box" class="readonly-box"
:class="{ 'is-positive': field.control === 'select_yny' && isPositiveQual(form[field.key]) }" :class="{ 'is-positive': field.control === 'select_yny' && isPositiveQual(form[field.key]) }"
@@ -112,32 +112,56 @@
</div> </div>
<template #footer> <template #footer>
<template v-if="mode === 'edit'"> <div class="storage-cert-qc-dialog__footer">
<el-button @click="emit('update:visible', false)"> <div class="footer-left">
<el-icon><Close /></el-icon> <template v-if="canConfigureAcl">
取消 <el-button @click="openAcl('qc')">
</el-button> <el-icon><Setting /></el-icon>
<el-button type="primary" :loading="saving" @click="handleSave"> 质检权限
<el-icon><Check /></el-icon> </el-button>
确定 <el-button @click="openAcl('production')">
</el-button> <el-icon><Setting /></el-icon>
</template> 生产权限
<el-button v-else type="primary" @click="emit('update:visible', false)"> </el-button>
<el-icon><Check /></el-icon> </template>
关闭 </div>
</el-button> <div class="footer-right">
<template v-if="mode === 'edit' && canSaveAny">
<el-button @click="emit('update:visible', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
<el-button v-else type="primary" @click="emit('update:visible', false)">
<el-icon><Check /></el-icon>
关闭
</el-button>
</div>
</div>
</template> </template>
</el-dialog> </el-dialog>
<StorageCertQcAclDialog
v-model:visible="aclVisible"
:scope="aclScope"
@saved="loadDetail"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue' import { Check, Close, Setting } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
fetchSampleStorageCertQc, fetchSampleStorageCertQc,
saveSampleStorageCertQc, saveSampleStorageCertQc,
type SampleStorageCertQcDetail type SampleStorageCertQcDetail,
type StorageCertQcAclScope
} from '@/api/sample-storage-cert-qc' } from '@/api/sample-storage-cert-qc'
import StorageCertQcAclDialog from '@/components/business/storage-cert-qc-acl-dialog/index.vue'
import { import {
STORAGE_CERT_QC_GROUPS, STORAGE_CERT_QC_GROUPS,
isPerinatalQualField, isPerinatalQualField,
@@ -166,6 +190,10 @@
const filledFlag = ref(false) const filledFlag = ref(false)
const typeKey = ref<StorageCertQcTypeKey | null>(null) const typeKey = ref<StorageCertQcTypeKey | null>(null)
const form = reactive<Record<string, string>>({}) const form = reactive<Record<string, string>>({})
const editableKeySet = ref<Set<string>>(new Set())
const canConfigureAcl = ref(false)
const aclVisible = ref(false)
const aclScope = ref<StorageCertQcAclScope>('qc')
const identity = reactive({ const identity = reactive({
labFlowCode: '', labFlowCode: '',
sampleCode: '', sampleCode: '',
@@ -174,7 +202,7 @@
}) })
const dialogTitle = computed(() => const dialogTitle = computed(() =>
props.mode === 'edit' ? '填写储存证质检信息' : '查看储存证的质检信息' props.mode === 'edit' && canSaveAny.value ? '填写储存证质检信息' : '查看储存证的质检信息'
) )
const codeFieldLabel = computed(() => { const codeFieldLabel = computed(() => {
@@ -184,6 +212,19 @@
const groups = computed(() => (typeKey.value ? STORAGE_CERT_QC_GROUPS[typeKey.value] || [] : [])) const groups = computed(() => (typeKey.value ? STORAGE_CERT_QC_GROUPS[typeKey.value] || [] : []))
const canSaveAny = computed(
() => props.mode === 'edit' && editableKeySet.value.size > 0
)
function isFieldEditable(key: string) {
return props.mode === 'edit' && editableKeySet.value.has(key)
}
function openAcl(scope: StorageCertQcAclScope) {
aclScope.value = scope
aclVisible.value = true
}
function displayValue(key: string) { function displayValue(key: string) {
const v = form[key] const v = form[key]
if (v == null || String(v).trim() === '') return '—' if (v == null || String(v).trim() === '') return '—'
@@ -213,6 +254,9 @@
identity.motherName = detail.motherName || '' identity.motherName = detail.motherName || ''
identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || '' identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || ''
filledFlag.value = !!detail.filledFlag filledFlag.value = !!detail.filledFlag
canConfigureAcl.value = !!detail.canConfigureAcl
const keys = Array.isArray(detail.editableFieldKeys) ? detail.editableFieldKeys : []
editableKeySet.value = new Set(keys.map((k) => String(k)))
const resolved = const resolved =
(detail.sampleTypeKey as StorageCertQcTypeKey | null) || (detail.sampleTypeKey as StorageCertQcTypeKey | null) ||
resolveStorageCertQcTypeKey(identity.sampleTypeName) resolveStorageCertQcTypeKey(identity.sampleTypeName)
@@ -234,6 +278,8 @@
} catch { } catch {
typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName) typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName)
filledFlag.value = false filledFlag.value = false
editableKeySet.value = new Set()
canConfigureAcl.value = false
resetFormKeys(typeKey.value, {}) resetFormKeys(typeKey.value, {})
identity.labFlowCode = '' identity.labFlowCode = ''
identity.sampleCode = code identity.sampleCode = code
@@ -249,6 +295,10 @@
ElMessage.warning('无法保存:样本类型不支持或缺少样本编码') ElMessage.warning('无法保存:样本类型不支持或缺少样本编码')
return return
} }
if (!canSaveAny.value) {
ElMessage.warning('当前无字段编辑权限')
return
}
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
for (const group of groups.value) { for (const group of groups.value) {
for (const field of group.fields) { for (const field of group.fields) {
@@ -283,6 +333,21 @@
min-height: 120px; min-height: 120px;
} }
.storage-cert-qc-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.footer-left,
.footer-right {
display: flex;
align-items: center;
gap: 8px;
}
.ctx-row { .ctx-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@@ -405,5 +470,15 @@
.form-col-span-3 { .form-col-span-3 {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.storage-cert-qc-dialog__footer {
flex-direction: column;
align-items: stretch;
}
.footer-left,
.footer-right {
justify-content: flex-end;
}
} }
</style> </style>
+86
View File
@@ -190,6 +190,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'biz:cellStoragePaymentConfirm:list' menuCode: 'biz:cellStoragePaymentConfirm:list'
} }
}, },
{
id: 'CEL0000000000000000000000000LSP1',
name: 'CellStorageLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'cellStorage:labSampleProcess:list'
}
},
// 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核 // 市场侧入库放行审核(总览)暂下线:改由生产/质检入口审核
// { // {
// id: '70000000000000000000000000000011', // id: '70000000000000000000000000000011',
@@ -922,6 +933,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
keepAlive: true, keepAlive: true,
menuCode: 'production:waste-output:list' menuCode: 'production:waste-output:list'
} }
},
{
id: 'PRM0000000000000000000000000LSP1',
name: 'ProductionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'production:labSampleProcess:list'
}
} }
] ]
}, },
@@ -1190,6 +1212,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'quality:storageReleaseReview:list' menuCode: 'quality:storageReleaseReview:list'
}, },
props: { reviewRole: 'qc' } props: { reviewRole: 'qc' }
},
{
id: '70000000000000000000000000000015',
name: 'QualityInspectionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'biz:labSampleProcess:list'
}
} }
] ]
}, },
@@ -1302,6 +1335,59 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
] ]
}, },
// ═══════════════════════════════════════════════════════════════════════════
// 3b. 实验室样本处理数据 (menu:lab-sample-processing)
// ═══════════════════════════════════════════════════════════════════════════
{
id: 'TOP0000000000000000000000000000L1',
name: 'LabSampleProcessing',
path: '/lab-sample-processing',
component: '/index/index',
meta: {
title: '实验室样本处理数据',
icon: 'ri:test-tube-line',
keepAlive: false,
isTopMenu: true,
menuCode: 'menu:lab-sample-processing',
roles: ['admin']
},
children: [
{
id: 'LAB00000000000000000000000000001',
name: 'LabSampleProcessingHsc',
path: 'hsc',
component: '/lab-sample-processing/hsc/index',
meta: {
title: '造血干细胞数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
},
{
id: 'LAB00000000000000000000000000002',
name: 'LabSampleProcessingUbmc',
path: 'ubmc',
component: '/lab-sample-processing/ubmc/index',
meta: {
title: '单个核数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
},
{
id: 'LAB00000000000000000000000000003',
name: 'LabSampleProcessingMsc',
path: 'msc',
component: '/lab-sample-processing/msc/index',
meta: {
title: '脐带间充质干细胞数据',
keepAlive: true,
menuCode: 'lab:sample-processing:list'
}
}
]
},
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// 4. 运营支撑 (menu:operations) // 4. 运营支撑 (menu:operations)
// 二级菜单:系统基础信息、运营支撑 // 二级菜单:系统基础信息、运营支撑
@@ -10,7 +10,8 @@ import {
buildFreightMergeRanges, buildFreightMergeRanges,
buildPresignLedgerRows, buildPresignLedgerRows,
buildResearchLedgerRows, buildResearchLedgerRows,
formatLedgerDateTime formatLedgerDateTime,
groupFlatRowsByFreightBatch
} from './collection-ledger-export' } from './collection-ledger-export'
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten' import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
@@ -194,6 +195,71 @@ describe('buildCellStorageLedgerRows', () => {
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 } { startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
]) ])
}) })
it('groups non-adjacent same freight batch before merging cells', () => {
const shared = {
protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' },
sampleCollectTime: '2025-09-11 18:13:00',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildCellStorageLedgerRows([
{
...shared,
motherName: '甲',
sampleCode: 'S1',
labStorageCode: 'UBMC1',
freightId: 'batch-cell'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '丙',
sampleCode: 'S3',
labStorageCode: 'UBMC3',
freightId: 'other',
freightTransportLabel: '快递',
freightAmountLabel: '25'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '乙',
sampleCode: 'S2',
labStorageCode: 'UBMC2',
freightId: 'batch-cell'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][8], '甲')
assert.equal(rows[1][8], '乙')
assert.equal(rows[2][8], '丙')
assert.equal(rows[0][52], '专线车')
assert.equal(rows[0][53], '100')
assert.equal(rows[1][52], '')
assert.equal(rows[1][53], '')
assert.equal(rows[2][52], '快递')
assert.equal(rows[2][53], '25')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 53, endCol: 53 },
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
])
})
})
describe('groupFlatRowsByFreightBatch', () => {
it('keeps first-seen batch order and leaves empty freight rows in place', () => {
const arranged = groupFlatRowsByFreightBatch([
{ motherName: 'A', freightId: 'f1' },
{ motherName: 'B', freightId: '' },
{ motherName: 'C', freightId: 'f1' },
{ motherName: 'D', freightId: 'f2' }
] as unknown as ProtocolAuditFlatRow[])
assert.deepEqual(
arranged.map((row) => row.motherName),
['A', 'C', 'B', 'D']
)
})
}) })
describe('buildResearchLedgerRows', () => { describe('buildResearchLedgerRows', () => {
@@ -335,6 +401,50 @@ describe('buildResearchLedgerRows', () => {
{ startRow: 3, endRow: 4, startCol: 18, endCol: 18 } { startRow: 3, endRow: 4, startCol: 18, endCol: 18 }
]) ])
}) })
it('groups non-adjacent same freight batch before merging research cells', () => {
const shared = {
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' },
sampleCollectTime: '2025-09-11 18:13:00',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildResearchLedgerRows([
{
...shared,
motherName: '甲',
labStorageCode: 'A1',
freightId: 'batch-a'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '散',
labStorageCode: 'A9',
freightId: '',
freightTransportLabel: '',
freightAmountLabel: ''
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '乙',
labStorageCode: 'A2',
freightId: 'batch-a'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][8], '甲')
assert.equal(rows[1][8], '乙')
assert.equal(rows[2][8], '散')
assert.equal(rows[0][16], '专线车')
assert.equal(rows[1][16], '')
assert.equal(rows[1][17], '')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 17, endCol: 17 },
{ startRow: 2, endRow: 3, startCol: 18, endCol: 18 }
])
})
}) })
describe('buildCustomerServiceLedgerRows', () => { describe('buildCustomerServiceLedgerRows', () => {
@@ -1,8 +1,5 @@
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten' import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
/** 采集大表导出入口开关;验收未完成前隐藏按钮,改 true 即可恢复。 */
export const COLLECTION_LEDGER_EXPORT_ENABLED = false
type Row = Record<string, unknown> type Row = Record<string, unknown>
type PayDetail = { type PayDetail = {
installmentNo?: number | null installmentNo?: number | null
@@ -341,6 +338,36 @@ function researchMaternalBloodText(value: unknown): string {
return text(value) ? text(value) : '无母血' return text(value) ? text(value) : '无母血'
} }
/**
* 将同一运费批次的行聚拢到一起(按首次出现顺序),无批次行保持相对位置。
* 模版要求同批次运输方式/运费合并单元格,故导出前先归拢再合并。
*/
export function groupFlatRowsByFreightBatch(
rows: ProtocolAuditFlatRow[]
): ProtocolAuditFlatRow[] {
const groups = new Map<string, ProtocolAuditFlatRow[]>()
const sequence: Array<{ kind: 'batch'; id: string } | { kind: 'single'; row: ProtocolAuditFlatRow }> =
[]
for (const row of rows) {
const freightId = text(row.freightId)
if (!freightId) {
sequence.push({ kind: 'single', row })
continue
}
if (!groups.has(freightId)) {
groups.set(freightId, [])
sequence.push({ kind: 'batch', id: freightId })
}
groups.get(freightId)!.push(row)
}
const arranged: ProtocolAuditFlatRow[] = []
for (const item of sequence) {
if (item.kind === 'single') arranged.push(item.row)
else arranged.push(...(groups.get(item.id) || []))
}
return arranged
}
/** /**
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。 * 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
* @param transportCol 运输方式列(1-based) * @param transportCol 运输方式列(1-based)
@@ -626,9 +653,10 @@ export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][] rows: string[][]
merges: LedgerMergeRange[] merges: LedgerMergeRange[]
} { } {
const freightIds = rows.map((row) => text(row.freightId)) const arranged = groupFlatRowsByFreightBatch(rows)
const built = rows.map((row, index) => storageLedgerRow(row, index, false)) const freightIds = arranged.map((row) => text(row.freightId))
// 自存表:运输方式=53,运费=54 const built = arranged.map((row, index) => storageLedgerRow(row, index, false))
// 自存表:运输方式=53,运费=54(对齐模版《自存表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) } return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
} }
@@ -636,9 +664,10 @@ export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][] rows: string[][]
merges: LedgerMergeRange[] merges: LedgerMergeRange[]
} { } {
const freightIds = rows.map((row) => text(row.freightId)) const arranged = groupFlatRowsByFreightBatch(rows)
const built = rows.map((row, index) => storageLedgerRow(row, index, true)) const freightIds = arranged.map((row) => text(row.freightId))
// 科研表:运输方式=17,运费=18 const built = arranged.map((row, index) => storageLedgerRow(row, index, true))
// 科研表:运输方式=17,运费=18(对齐模版《科研表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) } return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
} }
@@ -0,0 +1,138 @@
import type { LabSampleProcessTabKey } from '@/api/lab-sample-process'
export type LabColumn = {
prop: string
label: string
minWidth?: number
}
export const LAB_PROCESS_TABS: Array<{
key: LabSampleProcessTabKey
label: string
source: 'cell_storage' | 'research_donation'
sampleTypeName: string
}> = [
{
key: 'cell_hsc',
label: '细胞存储·围产期造血',
source: 'cell_storage',
sampleTypeName: '围产期造血干细胞'
},
{
key: 'donation_hsc',
label: '科研捐赠·围产期造血',
source: 'research_donation',
sampleTypeName: '围产期造血干细胞'
},
{
key: 'cell_msc',
label: '细胞存储·脐带间充质',
source: 'cell_storage',
sampleTypeName: '脐带间充质干细胞'
},
{
key: 'donation_msc',
label: '科研捐赠·脐带间充质',
source: 'research_donation',
sampleTypeName: '脐带间充质干细胞'
}
]
const CELL_HSC_COLUMNS: LabColumn[] = [
{ prop: 'cellCode', label: '细胞编码', minWidth: 140 },
{ prop: 'motherName', label: '准妈妈姓名', minWidth: 110 },
{ prop: 'collectionTime', label: '采集时间', minWidth: 200 },
{ prop: 'hospitalName', label: '采集医院', minWidth: 140 },
{ prop: 'receptionTime', label: '接收时间', minWidth: 200 },
{ prop: 'receiveTemperature', label: '样品温度到达/℃', minWidth: 130 },
{ prop: 'receiveCheckText', label: '收样检查', minWidth: 120 },
{ prop: 'hasMaternalBlood', label: '是否有母血', minWidth: 100 },
{ prop: 'remeasureMaternalBlood', label: '是否重采母血', minWidth: 110 },
{ prop: 'consentForm', label: '知情同意书或协议书', minWidth: 140 },
{ prop: 'transportPackingList', label: '脐血运输装箱清单', minWidth: 140 },
{ prop: 'cordBloodReceiveConfirm', label: '脐带血接收确认单', minWidth: 140 },
{ prop: 'healthSurvey', label: '孕妇健康调查表', minWidth: 120 },
{ prop: 'labelClear', label: '标识清晰', minWidth: 100 },
{ prop: 'handlerName', label: '接收登记人', minWidth: 110 },
{ prop: 'remarkReception', label: '备注', minWidth: 120 },
{ prop: 'originalBloodWeightG', label: '原血重量/g', minWidth: 110 },
{ prop: 'hesVolumeMl', label: 'HES用量/ml', minWidth: 110 },
{ prop: 'bloodType', label: '血型', minWidth: 80 },
{ prop: 'infectiousFourTestLabel', label: '传染四项检测(检测卡)', minWidth: 150 },
{ prop: 'infectiousFourExternal', label: '传染四项检测(外检)', minWidth: 150 },
{ prop: 'preEnrichNucleatedCount', label: '富集前有核细胞数/109/L', minWidth: 170 },
{ prop: 'enrichedBloodWeightG', label: '富集后血量/g', minWidth: 120 },
{ prop: 'postEnrichNucleatedCount', label: '富集后有核细胞数/109/L', minWidth: 170 },
{ prop: 'cryopreservationVolumeMl', label: '冻存液用量/ml', minWidth: 120 },
{ prop: 'cd34Count1e4', label: 'CD34+细胞计数/*10E4', minWidth: 160 },
{ prop: 'operatorName', label: '操作人', minWidth: 100 },
{ prop: 'remarkProcess', label: '备注', minWidth: 120 },
{ prop: 'createTime', label: '创建时间', minWidth: 200 }
]
const DONATION_HSC_COLUMNS: LabColumn[] = [
{ prop: 'cellCode', label: '细胞编码', minWidth: 140 },
{ prop: 'motherName', label: '准妈妈姓名', minWidth: 110 },
{ prop: 'labMaterialCode', label: '实验室物料编码', minWidth: 130 },
{ prop: 'inboundCode', label: '入库编码', minWidth: 140 },
{ prop: 'storageStatus', label: '储存状态', minWidth: 100 },
{ prop: 'discardReason', label: '废弃原因', minWidth: 120 },
{ prop: 'receptionTime', label: '接收时间', minWidth: 200 },
{ prop: 'receiveTemperature', label: '样品温度到达/℃', minWidth: 130 },
{ prop: 'receiveCheckText', label: '收样检查', minWidth: 120 },
{ prop: 'handlerName', label: '接收登记人', minWidth: 110 },
{ prop: 'remarkReception', label: '备注', minWidth: 120 },
{ prop: 'originalBloodVolumeMl', label: '原血体积/mL', minWidth: 120 },
{ prop: 'nucleatedCellCount1e8', label: '有核细胞数/108', minWidth: 130 },
{ prop: 'preOpNucleatedCount', label: '操作前有核细胞数/109/L', minWidth: 170 },
{ prop: 'infectiousFourTestLabel', label: '传染四项检测(检测卡)', minWidth: 150 },
{ prop: 'motherHbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', minWidth: 180 },
{ prop: 'motherAntiHcv', label: '丙型肝炎病毒抗体(抗HCV)', minWidth: 170 },
{ prop: 'motherAntiTp', label: '梅毒螺旋体特异性抗体(抗TP)', minWidth: 180 },
{ prop: 'motherAntiHiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', minWidth: 200 },
{ prop: 'motherCmvIgm', label: '巨细胞病毒IgM抗体(CMV-IgM)', minWidth: 180 },
{ prop: 'operatorName', label: '操作人', minWidth: 100 },
{ prop: 'remarkProcess', label: '备注', minWidth: 120 },
{ prop: 'createTime', label: '创建时间', minWidth: 200 }
]
const MSC_COLUMNS: LabColumn[] = [
{ prop: 'labFlowCode', label: '实验室流转编码', minWidth: 140 },
{ prop: 'motherName', label: '母亲姓名', minWidth: 110 },
{ prop: 'cordCollectTime', label: '脐带采集时间', minWidth: 200 },
{ prop: 'transportTempC', label: '运输温度/℃', minWidth: 110 },
{ prop: 'cordReceiveTime', label: '脐带接收时间', minWidth: 200 },
{ prop: 'handlerName', label: '接收登记人', minWidth: 110 },
{ prop: 'inoculationAmount', label: '接种量', minWidth: 100 },
{ prop: 'storageStatus', label: '储存状态', minWidth: 100 },
{ prop: 'cryopreservationTime', label: '冻存时间', minWidth: 200 },
{ prop: 'storageTotalTubes', label: '储存总量(管)', minWidth: 120 },
{ prop: 'availableCellCount1e7', label: '可提供细胞数量/*10E7', minWidth: 160 },
{ prop: 'endotoxinTest', label: '内毒素检测', minWidth: 100 },
{ prop: 'bacteriaTest', label: '细菌检测', minWidth: 100 },
{ prop: 'fungusTest', label: '真菌检测', minWidth: 100 },
{ prop: 'mycoplasmaTest', label: '支原体检测', minWidth: 100 },
{ prop: 'operatorName', label: '操作人', minWidth: 100 },
{ prop: 'motherHbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', minWidth: 180 },
{ prop: 'motherHbvDna', label: '乙型肝炎病毒核酸(HBV DNA)', minWidth: 180 },
{ prop: 'motherAntiHcv', label: '丙型肝炎病毒抗体(抗HCV)', minWidth: 170 },
{ prop: 'motherHcvRna', label: '丙型肝炎病毒核酸(HCV RNA)', minWidth: 180 },
{ prop: 'motherAntiTp', label: '梅毒螺旋体特异性抗体(抗TP)', minWidth: 180 },
{ prop: 'motherCmvIgm', label: '巨细胞病毒IgM抗体(CMV IgM)', minWidth: 180 },
{ prop: 'motherAntiHiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', minWidth: 200 },
{ prop: 'motherHiv1Rna', label: '人类免疫缺陷病毒核酸(1型)(HIV-1RNA)', minWidth: 210 },
{ prop: 'storageDateP2', label: '储存日期(P2冻存日期)', minWidth: 160 },
{ prop: 'remark', label: '备注', minWidth: 120 },
{ prop: 'createTime', label: '创建时间', minWidth: 200 }
]
export const LAB_PROCESS_COLUMNS: Record<LabSampleProcessTabKey, LabColumn[]> = {
cell_hsc: CELL_HSC_COLUMNS,
donation_hsc: DONATION_HSC_COLUMNS,
cell_msc: MSC_COLUMNS,
donation_msc: MSC_COLUMNS
}
export const SUPPLEMENT_FIELD_LABELS: Record<string, string> = Object.fromEntries(
[...CELL_HSC_COLUMNS, ...DONATION_HSC_COLUMNS, ...MSC_COLUMNS].map((c) => [c.prop, c.label])
)
+13 -3
View File
@@ -33,7 +33,8 @@ export function supportsStorageCertQc(sampleTypeName?: string | null): boolean {
return resolveStorageCertQcTypeKey(sampleTypeName) != null return resolveStorageCertQcTypeKey(sampleTypeName) != null
} }
const MOTHER_BLOOD: StorageCertQcField[] = [ /** 围产期造血:母亲血液含三项核酸检测。 */
const MOTHER_BLOOD_PERINATAL: StorageCertQcField[] = [
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' }, { key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' }, { key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' },
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' }, { key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
@@ -48,6 +49,15 @@ const MOTHER_BLOOD: StorageCertQcField[] = [
} }
] ]
/** 脐带间充质:不展示 HBV DNA / HCV RNA / HIV-1RNA。 */
const MOTHER_BLOOD_UC_MSC: StorageCertQcField[] = [
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
{ key: 'mother_anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
{ key: 'mother_cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV IgM)', control: 'select_yny' },
{ key: 'mother_anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' }
]
export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = { export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = {
perinatal_hsc: [ perinatal_hsc: [
{ {
@@ -77,7 +87,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
{ key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' } { key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' }
] ]
}, },
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD }, { title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_PERINATAL },
{ {
title: '储存证收尾信息', title: '储存证收尾信息',
variant: 'meta', variant: 'meta',
@@ -120,7 +130,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
} }
] ]
}, },
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD }, { title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_UC_MSC },
{ {
title: '储存证收尾信息', title: '储存证收尾信息',
variant: 'meta', variant: 'meta',
@@ -75,12 +75,7 @@
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
新增 新增
</el-button> </el-button>
<el-button <el-button class="btn-ghost" :loading="exporting" @click="handleExport">
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
导出 Excel 导出 Excel
</el-button> </el-button>
@@ -467,7 +462,6 @@
} from '@/utils/protocol-doc-label' } from '@/utils/protocol-doc-label'
import { loadVoidTypeDict } from '@/utils/void-type-dict' import { loadVoidTypeDict } from '@/utils/void-type-dict'
import { import {
COLLECTION_LEDGER_EXPORT_ENABLED,
RESEARCH_HEADERS, RESEARCH_HEADERS,
buildResearchLedgerRows, buildResearchLedgerRows,
fetchAllPages, fetchAllPages,
@@ -79,12 +79,7 @@
<el-icon><Plus /></el-icon> <el-icon><Plus /></el-icon>
新增 新增
</el-button> </el-button>
<el-button <el-button class="btn-ghost" :loading="exporting" @click="handleExport">
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
导出 Excel 导出 Excel
</el-button> </el-button>
@@ -435,7 +430,6 @@
} from '@/utils/status-tag-registry' } from '@/utils/status-tag-registry'
import { loadVoidTypeDict } from '@/utils/void-type-dict' import { loadVoidTypeDict } from '@/utils/void-type-dict'
import { import {
COLLECTION_LEDGER_EXPORT_ENABLED,
CELL_STORAGE_HEADERS, CELL_STORAGE_HEADERS,
buildCellStorageLedgerRows, buildCellStorageLedgerRows,
fetchAllPages, fetchAllPages,
@@ -93,7 +93,7 @@
登记入库 登记入库
</el-button> </el-button>
<el-button <el-button
v-if="isCellStorage && COLLECTION_LEDGER_EXPORT_ENABLED" v-if="isCellStorage"
class="btn-ghost" class="btn-ghost"
:loading="exporting" :loading="exporting"
@click="handleExport" @click="handleExport"
@@ -243,7 +243,7 @@
<template #default="{ row }">{{ row.newbornIdCard || '—' }}</template> <template #default="{ row }">{{ row.newbornIdCard || '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="newbornSex" label="性别" min-width="70" align="center"> <el-table-column prop="newbornSex" label="性别" min-width="70" align="center">
<template #default="{ row }">{{ sexLabel(row.newbornSex) }}</template> <template #default="{ row }">{{ formatNewbornSex(row.newbornSex) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="newbornDeliveryTime" label="分娩时间" min-width="160" align="center" show-overflow-tooltip> <el-table-column prop="newbornDeliveryTime" label="分娩时间" min-width="160" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.newbornDeliveryTime || '—' }}</template> <template #default="{ row }">{{ row.newbornDeliveryTime || '—' }}</template>
@@ -510,6 +510,7 @@
donationStatusTagType, donationStatusTagType,
handleDoneFlagLabel, handleDoneFlagLabel,
handleDoneFlagTagType, handleDoneFlagTagType,
newbornSexLabel,
protocolStatusDisplayLabel, protocolStatusDisplayLabel,
protocolStatusTagType, protocolStatusTagType,
sampleCollectionFlagLabel, sampleCollectionFlagLabel,
@@ -522,7 +523,6 @@
sampleStorageFlagTagType sampleStorageFlagTagType
} from '@/utils/status-tag-registry' } from '@/utils/status-tag-registry'
import { import {
COLLECTION_LEDGER_EXPORT_ENABLED,
CUSTOMER_SERVICE_HEADERS, CUSTOMER_SERVICE_HEADERS,
buildCustomerServiceLedgerRows, buildCustomerServiceLedgerRows,
fetchAllPages, fetchAllPages,
@@ -572,11 +572,11 @@
const codeCorrectionVisible = ref(false) const codeCorrectionVisible = ref(false)
const codeCorrectionOldCode = ref('') const codeCorrectionOldCode = ref('')
function sexLabel(sex: unknown) { /** 与协议表单一致:'1'=男,'0'=女;空值显示 —(勿与客户 sexLabel 0男1女混淆) */
const s = String(sex || '').trim() function formatNewbornSex(sex: unknown) {
if (s === '1' || s === '男') return '男' const s = sex == null || sex === '' ? '' : String(sex).trim()
if (s === '2' || s === '女') return '女' if (!s) return '—'
return s || '—' return newbornSexLabel(s) || '—'
} }
function openStorageCertQcEdit(row: Record<string, unknown>) { function openStorageCertQcEdit(row: Record<string, unknown>) {
@@ -0,0 +1,342 @@
<!-- 实验室样本处理数据 — 共享列表页 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">{{ pageTitle }}</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-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
<el-button class="btn-main" type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
共 <span class="sel-num">{{ pagination.total }}</span> 条
</div>
<el-button class="btn-main" type="primary" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
size="small"
row-key="id"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
v-for="col in visibleColumns"
:key="col.key"
:prop="col.key"
:label="col.label"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
fixed="right"
min-width="260"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-button
v-if="(acl.editableDepts || []).length"
class="btn-ghost"
link
type="primary"
@click="openEdit(row)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<span v-else class="text-muted">只读</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</section>
<el-dialog
v-model="editVisible"
class="dialog-form-layout"
:title="`编辑 — ${editRow?.sampleCode || ''}`"
width="860px"
align-center
destroy-on-close
append-to-body
>
<el-form label-position="top" class="form-grid-2" v-loading="saving">
<template v-for="group in editGroups" :key="group.dept">
<div class="form-col-span-2 dept-title">{{ group.label }}</div>
<el-form-item v-for="f in group.fields" :key="f.key" :label="f.label">
<el-input
v-model="editForm[f.key]"
:disabled="!canEditDept(f.dept)"
clearable
/>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="editVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="saving" @click="submitEdit">
<el-icon><Check /></el-icon>确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Check, Close, Download, Edit, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
fetchLabSampleList,
updateLabSampleRow,
type LabSampleAcl,
type LabSampleDataset,
type LabSampleFieldMeta
} from '@/api/lab-sample-processing'
const props = defineProps<{
dataset: LabSampleDataset
title: string
}>()
const pageTitle = computed(() => props.title)
const loading = ref(false)
const saving = ref(false)
const exporting = ref(false)
const tableData = ref<Record<string, any>[]>([])
const fields = ref<LabSampleFieldMeta[]>([])
const acl = ref<LabSampleAcl>({})
const searchForm = reactive({ keyword: '' })
const dateRange = ref<string[] | null>(null)
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
const editVisible = ref(false)
const editRow = ref<Record<string, any> | null>(null)
const editForm = reactive<Record<string, any>>({})
const deptLabel: Record<string, string> = {
market: '市场字段',
production: '生产字段',
qc: '质检字段'
}
const visibleColumns = computed(() => fields.value)
const editGroups = computed(() => {
const groups = ['market', 'production', 'qc'].map((dept) => ({
dept,
label: deptLabel[dept] || dept,
fields: fields.value.filter((f) => f.dept === dept)
}))
return groups.filter((g) => g.fields.length > 0)
})
function canEditDept(dept: string) {
const list = acl.value.editableDepts || []
return list.includes(dept)
}
async function loadData() {
loading.value = true
try {
const res = await fetchLabSampleList(props.dataset, {
keyword: searchForm.keyword || undefined,
dateFrom: dateRange.value?.[0],
dateTo: dateRange.value?.[1],
page: pagination.page,
pageSize: pagination.pageSize
})
tableData.value = res?.list || []
pagination.total = res?.total || 0
fields.value = res?.fields || []
acl.value = res?.acl || {}
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
void loadData()
}
function handleReset() {
searchForm.keyword = ''
dateRange.value = null
handleSearch()
}
function openEdit(row: Record<string, any>) {
editRow.value = row
Object.keys(editForm).forEach((k) => delete editForm[k])
for (const f of fields.value) {
editForm[f.key] = row[f.key] ?? ''
}
editVisible.value = true
}
async function submitEdit() {
if (!editRow.value?.sampleCode) return
const editableKeys = fields.value
.filter((f) => canEditDept(f.dept))
.map((f) => f.key)
if (!editableKeys.length) {
ElMessage.warning('当前账号无可编辑字段')
return
}
const payload: Record<string, any> = {}
for (const key of editableKeys) {
payload[key] = editForm[key]
}
saving.value = true
try {
await updateLabSampleRow(props.dataset, editRow.value.sampleCode, payload)
ElMessage.success('保存成功')
editVisible.value = false
await loadData()
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleExport() {
exporting.value = true
try {
// 列表接口 pageSize 上限为 200,导出需分页拉全量后再生成 xlsx
const pageSize = 200
let page = 1
let total = Number.POSITIVE_INFINITY
const rows: Record<string, any>[] = []
let cols = fields.value
while (rows.length < total) {
const res = await fetchLabSampleList(props.dataset, {
keyword: searchForm.keyword || undefined,
dateFrom: dateRange.value?.[0],
dateTo: dateRange.value?.[1],
page,
pageSize
})
if (res?.fields?.length) cols = res.fields
total = Number(res?.total ?? 0)
const batch = res?.list || []
rows.push(...batch)
if (!batch.length || batch.length < pageSize) break
page += 1
if (page > 500) break
}
const ExcelJS = (await import('exceljs')).default
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(props.title || props.dataset)
sheet.addRow(cols.map((c) => c.label))
for (const row of rows) {
sheet.addRow(cols.map((c) => row[c.key] ?? ''))
}
const header = sheet.getRow(1)
header.font = { bold: true }
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${props.title || props.dataset}.xlsx`
a.click()
URL.revokeObjectURL(a.href)
ElMessage.success(`已导出 ${rows.length} 条`)
} catch (e: any) {
ElMessage.error(e?.message || '导出失败')
} finally {
exporting.value = false
}
}
onMounted(() => {
void loadData()
})
</script>
<style scoped lang="scss">
.dept-title {
font-weight: 600;
color: var(--el-color-primary);
margin: 4px 0 8px;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="hsc" title="造血干细胞数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="msc" title="脐带间充质干细胞数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>
@@ -0,0 +1,7 @@
<template>
<LabSampleProcessingPage dataset="ubmc" title="单个核数据" />
</template>
<script setup lang="ts">
import LabSampleProcessingPage from '../LabSampleProcessingPage.vue'
</script>
@@ -0,0 +1,349 @@
<!-- 质检管理 — 实验室样本处理数据 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-pill-tabs panel-pill-tabs--lg">
<div class="panel-pill-tabs__track" role="tablist">
<button
v-for="tab in LAB_PROCESS_TABS"
:key="tab.key"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': activeTab === tab.key }"
:aria-selected="activeTab === tab.key"
@click="handleTabChange(tab.key)"
>
{{ tab.label }}
</button>
</div>
</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.labStorageCode"
placeholder="实验室入库编码"
clearable
/>
</el-form-item>
<el-form-item label="母亲姓名">
<el-input v-model="searchForm.motherName" placeholder="母亲/准妈妈姓名" clearable />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="searchForm.createTimeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
clearable
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
共 <span class="sel-num">{{ pagination.total }}</span> 条
</div>
<el-button class="btn-main" type="primary" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="receptionId"
>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
v-for="col in currentColumns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || 120"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ displayCell(row[col.prop]) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="openSupplement(row)">
<el-icon><Edit /></el-icon>补录
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="loadData"
@current-change="loadData"
/>
</div>
</div>
</section>
<el-dialog
v-model="supplementVisible"
title="补录"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="biz-dialog-form">
<p class="form-tip"
>入库编码:{{ supplementForm.labStorageCode || '—' }}(补录数据跟随实验室入库编码)</p
>
<div class="form-grid-2">
<el-form-item
v-for="key in supplementKeys"
:key="key"
:label="SUPPLEMENT_FIELD_LABELS[key] || key"
>
<el-input v-model="supplementForm.payload[key]" clearable />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="supplementForm.remark" type="textarea" :rows="2" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="supplementVisible = false"
><el-icon><Close /></el-icon>取消</el-button
>
<el-button class="btn-main" type="primary" :loading="saving" @click="submitSupplement"
><el-icon><Check /></el-icon>确定</el-button
>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { Check, Close, Download, Edit, RefreshLeft, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as XLSX from 'xlsx'
import {
fetchLabSampleProcessList,
fetchLabSampleProcessSupplement,
saveLabSampleProcessSupplement,
type LabSampleProcessTabKey
} from '@/api/lab-sample-process'
import {
LAB_PROCESS_COLUMNS,
LAB_PROCESS_TABS,
SUPPLEMENT_FIELD_LABELS
} from '@/utils/lab-sample-process-columns'
defineOptions({ name: 'QualityInspectionLabSampleProcess' })
const activeTab = ref<LabSampleProcessTabKey>('cell_hsc')
const loading = ref(false)
const saving = ref(false)
const tableData = ref<Record<string, unknown>[]>([])
const supplementKeys = ref<string[]>([])
const searchForm = reactive({
labStorageCode: '',
motherName: '',
createTimeRange: null as [string, string] | null
})
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const currentColumns = computed(() => LAB_PROCESS_COLUMNS[activeTab.value] || [])
const currentTabMeta = computed(
() => LAB_PROCESS_TABS.find((t) => t.key === activeTab.value) || LAB_PROCESS_TABS[0]
)
const supplementVisible = ref(false)
const supplementForm = reactive({
labStorageCode: '',
receptionSource: 'cell_storage',
sampleTypeName: '',
remark: '',
payload: {} as Record<string, string>
})
function displayCell(value: unknown) {
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
async function loadData() {
loading.value = true
try {
const range = searchForm.createTimeRange
const res = await fetchLabSampleProcessList({
tabKey: activeTab.value,
page: pagination.page,
pageSize: pagination.pageSize,
labStorageCode: searchForm.labStorageCode || undefined,
motherName: searchForm.motherName || undefined,
createTimeStart: range?.[0],
createTimeEnd: range?.[1]
})
tableData.value = (res?.list || []) as Record<string, unknown>[]
pagination.total = Number(res?.total || 0)
supplementKeys.value = (res?.supplementKeys || []) as string[]
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.labStorageCode = ''
searchForm.motherName = ''
searchForm.createTimeRange = null
handleSearch()
}
function handleTabChange(name: LabSampleProcessTabKey) {
if (activeTab.value === name) return
activeTab.value = name
pagination.page = 1
loadData()
}
async function openSupplement(row: Record<string, unknown>) {
const code = String(row.labStorageCode || row.cellCode || row.labFlowCode || '').trim()
if (!code) {
ElMessage.warning('缺少实验室入库编码,无法补录')
return
}
const meta = currentTabMeta.value
supplementForm.labStorageCode = code
supplementForm.receptionSource = meta.source
supplementForm.sampleTypeName = meta.sampleTypeName
supplementForm.remark = ''
const keys = supplementKeys.value.length
? supplementKeys.value
: currentColumns.value.map((c) => c.prop)
const payload: Record<string, string> = {}
keys.forEach((k) => {
payload[k] = ''
})
try {
const data = await fetchLabSampleProcessSupplement(code)
const existing = (data?.payload || {}) as Record<string, string>
keys.forEach((k) => {
payload[k] = existing[k] != null ? String(existing[k]) : ''
})
supplementForm.remark = data?.remark || ''
} catch {
// 无补录记录时用空表单
}
supplementForm.payload = payload
supplementVisible.value = true
}
async function submitSupplement() {
saving.value = true
try {
await saveLabSampleProcessSupplement({
labStorageCode: supplementForm.labStorageCode,
receptionSource: supplementForm.receptionSource,
sampleTypeName: supplementForm.sampleTypeName,
payload: { ...supplementForm.payload },
remark: supplementForm.remark
})
ElMessage.success('补录已保存')
supplementVisible.value = false
loadData()
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
function handleExport() {
const cols = currentColumns.value
const headers = ['序号', ...cols.map((c) => c.label)]
const rows = tableData.value.map((row, idx) => {
const line: Record<string, string | number> = {
序号: (pagination.page - 1) * pagination.pageSize + idx + 1
}
cols.forEach((c) => {
const v = row[c.prop]
line[c.label] = v === null || v === undefined || v === '' ? '' : String(v)
})
return line
})
const sheet = XLSX.utils.json_to_sheet(rows, { header: headers })
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, sheet, currentTabMeta.value.label.slice(0, 31))
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')
XLSX.writeFile(workbook, `实验室样本处理数据_${currentTabMeta.value.key}_${stamp}.xlsx`)
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.form-tip {
margin: 0 0 12px;
color: var(--mint-text-muted, #6b7280);
font-size: 13px;
}
</style>
@@ -70,21 +70,16 @@
<dt>样本类型</dt> <dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</dd> <dd>{{ row.sampleTypeName || '—' }}</dd>
</div> </div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment"> <div
<dt>{{ attachmentMeta(row).label }}</dt> v-for="group in protocolAttachmentGroups(row)"
:key="group.label"
class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment"
>
<dt>{{ group.label }}</dt>
<dd> <dd>
<ProtocolAttachmentMiniReadonly <ProtocolAttachmentMiniReadonly
:files="attachmentMeta(row).files" :files="group.files"
@preview="handleAttachmentPreview" empty-text="暂无附件"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
@preview="handleAttachmentPreview" @preview="handleAttachmentPreview"
@download="handleAttachmentDownload" @download="handleAttachmentDownload"
/> />
@@ -252,10 +247,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue' import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue' import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue' import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import { import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions' import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' }) defineOptions({ name: 'SampleReceptionCardList' })
@@ -293,12 +285,8 @@
const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions() const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
function attachmentMeta(row: Record<string, unknown>) { function protocolAttachmentGroups(row: Record<string, unknown>) {
return getSourceProtocolAttachment(row) return getSourceProtocolAttachmentGroups(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
} }
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string { function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {