feat(lab-sample): 实验室样本处理三表台账与部门字段 ACL
按造血/单个核/间充质聚合展示,部门员工只改本部门、主管可改全部,并支持筛选与 Excel 导出;同步纠正采集接受人串权与严格菜单守卫。
This commit is contained in:
1 parent
58b252ce6b
commit
5fe1193a2c
22 files changed
+2136
-5
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user