Files
zkxw/backend/app/services/lab_sample_processing.py
T
zlwu 5fe1193a2c feat(lab-sample): 实验室样本处理三表台账与部门字段 ACL
按造血/单个核/间充质聚合展示,部门员工只改本部门、主管可改全部,并支持筛选与 Excel 导出;同步纠正采集接受人串权与严格菜单守卫。
2026-11-12 12:48:05 +08:00

645 lines
21 KiB
Python

"""实验室样本处理数据 — 聚合读取、Excel 导入与部门字段回写。"""
from __future__ import annotations
import json
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleProductionReception,
BizSampleReception,
)
from app.services.lab_sample_processing_fields import (
DATASET_HSC,
DATASET_MSC,
DATASET_UBMC,
DEPT_EDIT_PERMS,
PERM_LIST,
PERM_MANAGER,
SOURCE_EXTRA,
SOURCE_PRODUCTION,
SOURCE_PROTOCOL,
SOURCE_RECEPTION,
editable_depts_for_user,
field_map,
fields_for,
resolve_dataset_by_type_name,
)
def _new_id() -> str:
return uuid.uuid4().hex.upper()
def _fmt_dt(v: Any) -> Optional[str]:
if v is None:
return None
if isinstance(v, datetime):
return v.strftime("%Y-%m-%d %H:%M:%S")
return str(v)
def _fmt_num(v: Any) -> Any:
if v is None:
return None
if isinstance(v, Decimal):
return float(v)
return v
def _parse_extra(raw: Optional[str]) -> Dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except Exception:
return {}
return data if isinstance(data, dict) else {}
def user_acl(db: Session, user: dict) -> Dict[str, Any]:
from app.core.menu_guard import user_has_menu_perm
is_manager = user_has_menu_perm(db, user, PERM_MANAGER)
dept_flags = {
dept: user_has_menu_perm(db, user, code) for dept, code in DEPT_EDIT_PERMS.items()
}
editable = editable_depts_for_user(is_manager=is_manager, dept_flags=dept_flags)
return {
"canList": user_has_menu_perm(db, user, PERM_LIST) or is_manager or any(dept_flags.values()),
"isManager": is_manager,
"editableDepts": sorted(editable),
"deptFlags": dept_flags,
}
def _type_name_filters(dataset: str):
if dataset == DATASET_HSC:
return "%造血%"
if dataset == DATASET_MSC:
return "%间充质%"
if dataset == DATASET_UBMC:
return "%单个核%"
return None
def _code_prefix_filters(dataset: str) -> List[str]:
if dataset == DATASET_HSC:
return ["B%"]
if dataset == DATASET_MSC:
return ["BC%", "UC%"]
if dataset == DATASET_UBMC:
return ["UBMC%"]
return []
def _match_keyword(row: Dict[str, Any], kw: str) -> bool:
if not kw:
return True
text = f"{row.get('sampleCode') or ''} {row.get('motherName') or ''}"
return kw.lower() in text.lower()
def _match_date_range(row: Dict[str, Any], date_from: Optional[str], date_to: Optional[str]) -> bool:
if not date_from and not date_to:
return True
for key in ("receptionTime", "collectTime", "cryoTime", "storageDateP2"):
val = row.get(key)
if not val:
continue
day = str(val)[:10].replace(".", "-").replace("/", "-")
# normalize compact dates like 2025-9-8
parts = day.split("-")
if len(parts) == 3:
try:
day = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}"
except Exception:
pass
if date_from and day < date_from:
continue
if date_to and day > date_to:
continue
return True
return False
def list_rows(
db: Session,
*,
tenant_no: str,
dataset: str,
keyword: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[Dict[str, Any]], int]:
from app.models.models import BizLabSampleProcessingExtra
type_like = _type_name_filters(dataset)
code_likes = _code_prefix_filters(dataset)
q = (
db.query(
BizProtocolSample,
BizCellSampleType,
BizProtocolPartyCustomer,
BizProtocolCollectionInfo,
BizCollectionHospital,
)
.outerjoin(
BizCellSampleType,
(BizCellSampleType.id == BizProtocolSample.sample_type_id)
& (BizCellSampleType.del_flag == 0),
)
.outerjoin(
BizProtocolPartyCustomer,
(BizProtocolPartyCustomer.collection_protocol_id == BizProtocolSample.collection_protocol_id)
& (BizProtocolPartyCustomer.tenant_no == tenant_no)
& (BizProtocolPartyCustomer.del_flag == 0),
)
.outerjoin(
BizProtocolCollectionInfo,
(BizProtocolCollectionInfo.collection_protocol_id == BizProtocolSample.collection_protocol_id)
& (BizProtocolCollectionInfo.tenant_no == tenant_no)
& (BizProtocolCollectionInfo.del_flag == 0),
)
.outerjoin(
BizCollectionHospital,
(BizCollectionHospital.id == BizProtocolCollectionInfo.hospital_id)
& (BizCollectionHospital.del_flag == 0),
)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
BizProtocolSample.sample_code.isnot(None),
BizProtocolSample.sample_code != "",
)
)
type_or_code = []
if type_like:
type_or_code.append(BizCellSampleType.type_name.like(type_like))
for pref in code_likes:
type_or_code.append(BizProtocolSample.sample_code.like(pref))
if type_or_code:
q = q.filter(or_(*type_or_code))
rows = q.order_by(BizProtocolSample.create_time.desc()).all()
filtered = []
for sample, stype, party, coll, hospital in rows:
type_name = (stype.type_name if stype else "") or ""
code = sample.sample_code or ""
if resolve_dataset_by_type_name(type_name, code) != dataset:
continue
filtered.append((sample, stype, party, coll, hospital))
extras = {
e.sample_code: e
for e in db.query(BizLabSampleProcessingExtra)
.filter(
BizLabSampleProcessingExtra.tenant_no == tenant_no,
BizLabSampleProcessingExtra.del_flag == 0,
BizLabSampleProcessingExtra.dataset == dataset,
)
.all()
}
codes = list({*(s.sample_code for s, *_ in filtered if s.sample_code), *extras.keys()})
receptions: Dict[str, BizSampleReception] = {}
productions: Dict[str, BizSampleProductionReception] = {}
if codes:
for r in (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.sample_code.in_(codes),
)
.order_by(BizSampleReception.reception_time.desc())
.all()
):
receptions.setdefault(r.sample_code, r)
if receptions:
rids = [r.id for r in receptions.values()]
for p in (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.del_flag == 0,
BizSampleProductionReception.reception_id.in_(rids),
)
.order_by(BizSampleProductionReception.process_time.desc())
.all()
):
productions.setdefault(p.reception_id, p)
built_map: Dict[str, Dict[str, Any]] = {}
for sample, stype, party, coll, hospital in filtered:
code = sample.sample_code
reception = receptions.get(code)
production = productions.get(reception.id) if reception else None
extra = _parse_extra(extras[code].payload_json if code in extras else None)
built_map[code] = _build_row(
dataset=dataset,
sample=sample,
type_name=(stype.type_name if stype else None),
party=party,
coll=coll,
hospital=hospital,
reception=reception,
production=production,
extra=extra,
)
# Excel/补充表独有样本也要展示
for code, extra_row in extras.items():
if code in built_map:
continue
extra = _parse_extra(extra_row.payload_json)
reception = receptions.get(code)
production = productions.get(reception.id) if reception else None
built_map[code] = _build_row(
dataset=dataset,
sample=None,
type_name=None,
party=None,
coll=None,
hospital=None,
reception=reception,
production=production,
extra=extra,
)
kw = (keyword or "").strip()
built = []
for row in built_map.values():
if not _match_keyword(row, kw):
continue
if not _match_date_range(row, date_from, date_to):
continue
built.append(row)
# 稳定排序:优先按样本编码
built.sort(key=lambda r: str(r.get("sampleCode") or ""))
total = len(built)
page = max(1, int(page or 1))
page_size = max(1, min(5000, int(page_size or 20)))
start = (page - 1) * page_size
return built[start : start + page_size], total
def _build_row(
*,
dataset: str,
sample: Optional[BizProtocolSample],
type_name: Optional[str],
party: Optional[BizProtocolPartyCustomer],
coll: Optional[BizProtocolCollectionInfo],
hospital: Optional[BizCollectionHospital],
reception: Optional[BizSampleReception],
production: Optional[BizSampleProductionReception],
extra: Dict[str, Any],
) -> Dict[str, Any]:
protocol_vals = {
"sample_code": sample.sample_code if sample else extra.get("sampleCode"),
"mother_name": party.mother_name if party else None,
"sample_collect_time": coll.sample_collect_time if coll else None,
"hospital_name": hospital.hospital_name if hospital else None,
}
reception_vals = {}
if reception:
reception_vals = {
"reception_time": reception.reception_time,
"receive_temperature": reception.receive_temperature,
"handler_name": reception.handler_name,
"remark": reception.remark,
"lab_storage_code": reception.lab_storage_code,
}
production_vals = {}
if production:
production_vals = {
"original_blood_weight_g": production.original_blood_weight_g,
"hes_volume_ml": production.hes_volume_ml,
"blood_type": production.blood_type,
"infectious_four_test_result": production.infectious_four_test_result,
"enriched_blood_weight_g": production.enriched_blood_weight_g,
"cryopreservation_volume_ml": production.cryopreservation_volume_ml,
"handler_name": production.handler_name,
"remark": production.remark,
"original_blood_volume_ml": production.original_blood_volume_ml,
"nucleated_cell_count_1e8": production.nucleated_cell_count_1e8,
"inoculation_amount": production.inoculation_amount,
"cryopreservation_time": production.cryopreservation_time,
"storage_total_tubes": production.storage_total_tubes,
"available_cell_count_1e7": production.available_cell_count_1e7,
}
sample_code = (
(sample.sample_code if sample else None)
or extra.get("sampleCode")
or ""
)
data: Dict[str, Any] = {
"id": (sample.id if sample else f"extra:{dataset}:{sample_code}"),
"dataset": dataset,
"sampleCode": sample_code,
"sampleTypeName": type_name,
"protocolSampleId": sample.id if sample else None,
"collectionProtocolId": sample.collection_protocol_id if sample else None,
"receptionId": reception.id if reception else None,
"productionReceptionId": production.id if production else None,
"fromExtraOnly": sample is None,
}
for f in fields_for(dataset):
val = None
if f.source == SOURCE_PROTOCOL:
val = protocol_vals.get(f.attr)
elif f.source == SOURCE_RECEPTION:
val = reception_vals.get(f.attr)
elif f.source == SOURCE_PRODUCTION:
val = production_vals.get(f.attr)
elif f.source == SOURCE_EXTRA:
val = extra.get(f.key)
# 导入/补充表优先:有值则覆盖源表,保证 Excel 录入可见
if f.key in extra and extra.get(f.key) not in (None, ""):
val = extra.get(f.key)
if isinstance(val, datetime):
val = _fmt_dt(val)
elif isinstance(val, Decimal):
val = _fmt_num(val)
data[f.key] = val
if not data.get("sampleCode"):
data["sampleCode"] = sample_code
return data
def upsert_extra_payload(
db: Session,
*,
tenant_no: str,
dataset: str,
sample_code: str,
payload: Dict[str, Any],
operator_id: str = "import",
replace: bool = False,
) -> None:
from app.models.models import BizLabSampleProcessingExtra
code = (sample_code or "").strip()
if not code:
raise ValueError("样本编码不能为空")
now = datetime.now()
row = (
db.query(BizLabSampleProcessingExtra)
.filter(
BizLabSampleProcessingExtra.tenant_no == tenant_no,
BizLabSampleProcessingExtra.dataset == dataset,
BizLabSampleProcessingExtra.sample_code == code,
BizLabSampleProcessingExtra.del_flag == 0,
)
.first()
)
current = {} if replace else _parse_extra(row.payload_json if row else None)
for k, v in (payload or {}).items():
if v in (None, ""):
continue
current[k] = v
current["sampleCode"] = code
if row is None:
db.add(
BizLabSampleProcessingExtra(
id=_new_id(),
dataset=dataset,
sample_code=code,
payload_json=json.dumps(current, ensure_ascii=False),
tenant_no=tenant_no,
create_by=operator_id,
create_dept_id="",
create_time=now,
update_by=operator_id,
update_time=now,
del_flag=0,
)
)
else:
row.payload_json = json.dumps(current, ensure_ascii=False)
row.update_by = operator_id
row.update_time = now
def update_row(
db: Session,
*,
tenant_no: str,
dataset: str,
sample_code: str,
payload: Dict[str, Any],
editable_depts: set,
operator_id: str,
) -> Dict[str, Any]:
code = (sample_code or "").strip()
if not code:
raise ValueError("样本编码不能为空")
fmap = field_map(dataset)
if not fmap:
raise ValueError("未知数据集")
denied = []
accepted: Dict[str, Any] = {}
for key, value in (payload or {}).items():
f = fmap.get(key)
if not f:
continue
if f.dept not in editable_depts:
denied.append(key)
continue
accepted[key] = value
if denied:
raise PermissionError(f"无权编辑字段: {', '.join(denied)}")
if not accepted:
raise ValueError("没有可保存的字段")
sample = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == code,
BizProtocolSample.del_flag == 0,
)
.first()
)
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.sample_code == code,
BizSampleReception.del_flag == 0,
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
production = None
if reception:
production = (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.reception_id == reception.id,
BizSampleProductionReception.del_flag == 0,
)
.order_by(BizSampleProductionReception.process_time.desc())
.first()
)
party = None
coll = None
if sample:
party = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.collection_protocol_id == sample.collection_protocol_id,
BizProtocolPartyCustomer.tenant_no == tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
)
.first()
)
coll = (
db.query(BizProtocolCollectionInfo)
.filter(
BizProtocolCollectionInfo.collection_protocol_id == sample.collection_protocol_id,
BizProtocolCollectionInfo.tenant_no == tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
)
.first()
)
now = datetime.now()
extra_payload: Dict[str, Any] = {}
def _set_dt(obj, attr, value):
if value in (None, ""):
setattr(obj, attr, None)
return
if isinstance(value, datetime):
setattr(obj, attr, value)
return
text = str(value).strip().replace("/", "-").replace(".", "-")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
setattr(obj, attr, datetime.strptime(text, fmt))
return
except ValueError:
continue
setattr(obj, attr, value)
for key, value in accepted.items():
f = fmap[key]
wrote = False
if f.source == SOURCE_PROTOCOL and sample is not None:
if f.attr == "mother_name" and party is not None:
party.mother_name = None if value in (None, "") else str(value)
party.update_by = operator_id
party.update_time = now
wrote = True
elif f.attr == "sample_collect_time" and coll is not None:
_set_dt(coll, "sample_collect_time", value)
coll.update_by = operator_id
coll.update_time = now
wrote = True
elif f.source == SOURCE_RECEPTION and reception is not None:
if f.attr == "reception_time":
_set_dt(reception, f.attr, value)
elif f.attr in {"receive_temperature", "handler_name", "remark", "lab_storage_code"}:
setattr(reception, f.attr, None if value in (None, "") else value)
reception.update_by = operator_id
reception.update_time = now
wrote = True
elif f.source == SOURCE_PRODUCTION and production is not None:
numeric_attrs = {
"original_blood_weight_g",
"hes_volume_ml",
"enriched_blood_weight_g",
"cryopreservation_volume_ml",
"original_blood_volume_ml",
"nucleated_cell_count_1e8",
"available_cell_count_1e7",
}
if f.attr == "cryopreservation_time":
_set_dt(production, f.attr, value)
elif f.attr == "storage_total_tubes":
production.storage_total_tubes = None if value in (None, "") else int(value)
elif f.attr in numeric_attrs:
production_val = None if value in (None, "") else Decimal(str(value))
setattr(production, f.attr, production_val)
elif f.attr in {
"blood_type",
"infectious_four_test_result",
"handler_name",
"remark",
"inoculation_amount",
}:
setattr(production, f.attr, None if value in (None, "") else value)
production.update_by = operator_id
production.update_time = now
wrote = True
# 补充表始终保留一份,保证导入台账与无权源表场景可编辑
extra_payload[key] = value
_ = wrote
upsert_extra_payload(
db,
tenant_no=tenant_no,
dataset=dataset,
sample_code=code,
payload=extra_payload,
operator_id=operator_id,
replace=False,
)
db.commit()
rows, _ = list_rows(db, tenant_no=tenant_no, dataset=dataset, keyword=code, page=1, page_size=20)
for row in rows:
if row.get("sampleCode") == code:
return row
raise LookupError("保存成功但读取失败")
def export_rows(
db: Session,
*,
tenant_no: str,
dataset: str,
keyword: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
) -> Tuple[List[str], List[List[Any]]]:
rows, _ = list_rows(
db,
tenant_no=tenant_no,
dataset=dataset,
keyword=keyword,
date_from=date_from,
date_to=date_to,
page=1,
page_size=5000,
)
headers = [f.label for f in fields_for(dataset)]
keys = [f.key for f in fields_for(dataset)]
body = [[row.get(k) for k in keys] for row in rows]
return headers, body