Compare commits

..
Author SHA1 Message Date
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
18 changed files with 2271 additions and 34 deletions

No files matched your search

+2
View File
@@ -45,11 +45,13 @@ 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.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.lab_sample_process import router as lab_sample_process_router
from app.api.project_management import router as project_management_router
__all__ = [
"project_management_router",
"lab_sample_process_router",
"public_router",
"menu_router",
"admin_router",
+138
View File
@@ -0,0 +1,138 @@
"""实验室样本处理数据台账 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_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 = "biz:labSampleProcess:list"
SUPPLEMENT_MENU = "biz: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_menu_code(LIST_MENU)),
):
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_menu_code(LIST_MENU)),
):
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_menu_code(SUPPLEMENT_MENU)),
):
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}")
+13 -1
View File
@@ -9,7 +9,19 @@ def unicode_ci(col):
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)
+753
View File
@@ -0,0 +1,753 @@
"""实验室样本处理台账列表与补录 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)
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
+17 -16
View File
@@ -6,6 +6,7 @@ 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
@@ -22,8 +23,8 @@ def is_acl_strict(db: Session, tenant_no: str) -> bool:
row = (
db.query(BizStorageCertQcAcl.id)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.configured_flag == "1",
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.configured_flag, "1"),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
@@ -48,8 +49,8 @@ def resolve_editable_field_keys(
acls = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.configured_flag == "1",
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.configured_flag, "1"),
BizStorageCertQcAcl.del_flag == 0,
)
.all()
@@ -60,9 +61,9 @@ def resolve_editable_field_keys(
membership = (
db.query(BizStorageCertQcAclUser.acl_id)
.filter(
BizStorageCertQcAclUser.tenant_no == tenant_no,
BizStorageCertQcAclUser.acl_id.in_(acl_ids),
BizStorageCertQcAclUser.user_id == uid,
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()
@@ -115,8 +116,8 @@ def _validate_user_ids(db: Session, tenant_no: str, user_ids: Any) -> List[str]:
rows = (
db.query(SysUser.id)
.filter(
SysUser.tenant_no == tenant_no,
SysUser.id.in_(out),
eq_unicode(SysUser.tenant_no, tenant_no),
unicode_ci(SysUser.id).in_(out),
SysUser.del_flag == 0,
)
.all()
@@ -133,8 +134,8 @@ def get_acl_detail(db: Session, tenant_no: str, scope: str) -> Dict[str, Any]:
row = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.scope == scope_key,
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.scope, scope_key),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
@@ -152,11 +153,11 @@ def get_acl_detail(db: Session, tenant_no: str, scope: str) -> Dict[str, Any]:
db.query(BizStorageCertQcAclUser, SysUser)
.outerjoin(
SysUser,
(SysUser.id == BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
eq_unicode(SysUser.id, BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
)
.filter(
BizStorageCertQcAclUser.tenant_no == tenant_no,
BizStorageCertQcAclUser.acl_id == row.id,
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())
@@ -202,8 +203,8 @@ def upsert_acl(
row = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.scope == scope_key,
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(BizStorageCertQcAcl.scope, scope_key),
BizStorageCertQcAcl.del_flag == 0,
)
.first()
+2 -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_crypto_middleware import ApiCryptoMiddleware
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, project_management_router
@asynccontextmanager
@@ -157,6 +157,7 @@ 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(sample_cert_insurance_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(project_management_router, prefix=settings.API_V1_PREFIX)
+23
View File
@@ -2007,6 +2007,29 @@ class BizSampleProductionReception(Base):
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):
__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
+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()
@@ -1,6 +1,11 @@
"""储存证质检字段 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,
@@ -31,5 +36,66 @@ class TestAclFieldKeys(unittest.TestCase):
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()
@@ -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;
@@ -1,5 +1,6 @@
-- Issue #93:储存证质检信息字段级 ACL(质检/生产)。MySQL 5.7,可重复执行。
-- 不自动授予任何角色;管理员在角色管理中勾选「储存证质检字段权限配置」。
-- 菜单按钮「储存证质检字段权限配置」控制弹窗 footer 的「质检权限」「生产权限」入口;
-- 本脚本会将该按钮授权给当前租户下全部有效角色。
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl (
id VARCHAR(64) NOT NULL,
@@ -17,7 +18,7 @@ CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl (
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_scope (tenant_no, scope, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) 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,
@@ -35,7 +36,7 @@ CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl_user (
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;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
@@ -60,6 +61,48 @@ WHERE parent.del_flag = 0 AND parent.status = '1'
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;
+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
})
}
@@ -13,6 +13,16 @@
<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">
@@ -66,7 +76,7 @@
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { Check, Close, RefreshLeft, Select } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import {
@@ -113,6 +123,40 @@
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
@@ -126,15 +170,32 @@
page: 1,
pageSize: 50,
status: '1',
keyword: keyword || undefined
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 id = String(row.id || '')
const name = String(row.nickName || row.userName || row.userNo || id)
const account = String(row.userName || row.userNo || '')
upsertUserOption(id, account ? `${name}(${account})` : name)
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
}
@@ -149,14 +210,15 @@
selectedUserIds.value = Array.isArray(detail?.userIds) ? [...detail.userIds] : []
userOptions.value = []
for (const u of detail?.users || []) {
const id = String(u.id || '')
const name = String(u.nickName || u.userName || id)
const account = String(u.userName || '')
upsertUserOption(id, account ? `${name}(${account})` : name)
}
if (!userOptions.value.length) {
await searchUsers('')
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 = []
@@ -193,6 +255,13 @@
min-height: 280px;
}
.field-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.field-block {
margin-bottom: 14px;
}
+11
View File
@@ -1123,6 +1123,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
menuCode: 'biz:sampleReception:list'
}
},
{
id: '70000000000000000000000000000015',
name: 'QualityInspectionLabSampleProcess',
path: 'lab-sample-process',
component: '/quality-inspection/lab-sample-process/index',
meta: {
title: '实验室样本处理数据',
keepAlive: true,
menuCode: 'biz:labSampleProcess:list'
}
},
{
id: '70000000000000000000000000000007',
name: 'QualityInspectionQcWorkOrder',
@@ -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])
)
@@ -0,0 +1,347 @@
<!-- 质检管理 — 实验室样本处理数据 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">实验室样本处理数据</h2>
</div>
<el-tabs v-model="activeTab" class="lab-process-tabs" @tab-change="handleTabChange">
<el-tab-pane
v-for="tab in LAB_PROCESS_TABS"
:key="tab.key"
:label="tab.label"
:name="tab.key"
/>
</el-tabs>
<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() {
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">
.lab-process-tabs {
margin: 0 0 12px;
}
.form-tip {
margin: 0 0 12px;
color: var(--mint-text-muted, #6b7280);
font-size: 13px;
}
</style>