Files
zkxw/backend/app/services/sample_storage_cert_qc_prefill.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

552 lines
20 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""储存证质检信息:从已有业务数据补空字段。读不到的键不写入。
阴阳性只取检测参数里的阴/阳,不根据任务状态或结论推断;参数未填则留空。
细菌检测默认「阴性」。围产期检测结果评论、脐带检测结果评判默认「合格」。
同一采集协议下其他样本的母血可补当前样本缺的母亲血液项;本样本已有结果优先。不共享脐血。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy.orm import Session
from app.models.models import (
BizCellProduct,
BizProductionTask,
BizProductionTaskOutput,
BizProductionWorkOrder,
BizProtocolCollectionInfo,
BizProtocolSample,
BizQcTask,
BizQcTaskResult,
BizQcWorkOrder,
BizQcWorkOrderInspectionBatch,
BizSampleProductionReception,
BizSampleReception,
)
_FIXED_COMMON = {"storage_years": "20年"}
_FIXED_UC_MSC = {
"available_cell_type": "间充质干细胞",
"available_cell_morphology": "短梭/贴壁型",
"available_immunophenotype": "符合间充质干细胞表型特性",
"freeze_method": "程序降温盒降温",
"storage_temp": "-196℃",
}
def _compact(name: Optional[str]) -> str:
text = (name or "").strip().lower().replace("+", "+")
for ch in " \t\r\n**//()()【】\"'“”+":
text = text.replace(ch, "")
return text
def match_cd34_param(param_name: Optional[str]) -> Optional[str]:
"""CD34+细胞流式参数名 → 储存证字段。CD45dim 优先于 CD34 总数。"""
compact = _compact(param_name)
if not compact:
return None
if "cd45dim" in compact:
return "cd34_cd45dim_1e5"
if "tnc" in compact or "有核细胞" in compact:
return "tnc_1e8"
if "cd34" in compact and "cd45" not in compact:
return "cd34_count_1e6"
return None
def is_cd34_flow_item(item_name: Optional[str]) -> bool:
compact = _compact(item_name)
return "cd34" in compact and "流式" in compact
def match_process_qc_field(item_name: Optional[str]) -> Optional[str]:
name = item_name or ""
if "内毒素" in name:
return "endotoxin_test"
if "真菌" in name:
return "fungus_test"
if "支原体" in name:
return "mycoplasma_test"
return None
def qual_yin_yang(values: Iterable[str]) -> str:
"""只接受明确的阴/阳。通过、未检出、阴阳性同时出现都留空。"""
found = set()
for raw in values:
text = (raw or "").strip()
if text in ("阴", "阴性"):
found.add("阴")
elif text in ("阳", "阳性"):
found.add("阳")
if len(found) == 1:
return next(iter(found))
return ""
def normalize_negative_positive(value: Optional[str]) -> str:
"""储存证阴阳性下拉用「阴性 / 阳性」。"""
text = (value or "").strip()
if text in ("阳", "阳性"):
return "阳性"
if text in ("阴", "阴性"):
return "阴性"
return ""
def _fold_name(name: Optional[str]) -> str:
return (name or "").replace(" ", "").replace(" ", "").upper()
def batch_kind_from_name(batch_name: Optional[str]) -> str:
"""检测批次名含母血、脐血时区分储存证来源。母血优先,避免名称里同时出现时串项。"""
folded = _fold_name(batch_name)
if "母血" in folded:
return "母血"
if "脐血" in folded:
return "脐血"
return ""
def _first_negative_positive(values: Iterable[str]) -> str:
found = []
for raw in values:
qual = normalize_negative_positive(raw)
if qual and qual not in found:
found.append(qual)
if "阳性" in found:
return "阳性"
if "阴性" in found:
return "阴性"
return ""
def _item_has(item_name: Optional[str], *parts: str) -> bool:
folded = _fold_name(item_name)
return all(part.upper() in folded for part in parts)
def _param_surface_antigen(param_name: Optional[str]) -> bool:
folded = _fold_name(param_name)
return "表面抗原" in folded and "抗体" not in folded
# (字段, 批次, 项目是否命中, 参数是否命中;参数为空则取该项目里能识别的阴/阳)
_HSC_SEROLOGY_RULES = (
("hbsag", "脐血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
("anti_hcv", "脐血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
("anti_tp", "脐血", lambda name: _item_has(name, "梅毒", "抗体"), None),
("anti_hiv", "脐血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
("cmv_igm", "脐血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
("mother_hbsag", "母血", lambda name: _item_has(name, "乙肝五项"), _param_surface_antigen),
(
"mother_hbv_dna",
"母血",
lambda name: _item_has(name, "乙型肝炎病毒DNA") or _item_has(name, "乙型肝炎病毒核酸"),
None,
),
("mother_anti_hcv", "母血", lambda name: _item_has(name, "抗丙型肝炎病毒抗体"), None),
(
"mother_hcv_rna",
"母血",
lambda name: _item_has(name, "丙型肝炎病毒RNA") or _item_has(name, "丙型肝炎病毒核酸"),
None,
),
("mother_anti_tp", "母血", lambda name: _item_has(name, "梅毒", "抗体"), None),
("mother_cmv_igm", "母血", lambda name: _item_has(name, "巨细胞") and _item_has(name, "IGM"), None),
("mother_anti_hiv", "母血", lambda name: _item_has(name, "抗人类免疫缺陷病毒抗体"), None),
(
"mother_hiv1_rna",
"母血",
lambda name: _item_has(name, "人类免疫缺陷病毒", "核酸") or _item_has(name, "HIV-1RNA") or _item_has(name, "HIV1RNA"),
None,
),
)
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
def _serology_from_tasks(
tasks: List[Dict[str, Any]],
rules=_HSC_SEROLOGY_RULES,
) -> Dict[str, str]:
out: Dict[str, str] = {}
for field, kind, item_ok, param_ok in rules:
matched = [
task
for task in tasks
if task.get("batch_kind") == kind and item_ok(task.get("item_name") or "")
]
if not matched:
continue
chosen = max(matched, key=lambda task: task["stamp"])
values = []
for param_name, param_value in chosen.get("params") or []:
if param_ok is not None and not param_ok(param_name):
continue
values.append(param_value)
qual = _first_negative_positive(values)
if qual:
out[field] = qual
return out
def format_number(value: Any) -> str:
if value is None or value == "":
return ""
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return ""
text = format(number, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
def sum_cryo_volume(weight_g: Any, volume_ml: Any) -> str:
"""富集后血量与冻存液用量都有才相加。"""
if weight_g is None or volume_ml is None or weight_g == "" or volume_ml == "":
return ""
try:
total = Decimal(str(weight_g)) + Decimal(str(volume_ml))
except (InvalidOperation, ValueError):
return ""
return format_number(total)
def format_datetime(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
return ""
def format_date(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
return ""
def merge_prefill(saved: Optional[Dict[str, Any]], suggested: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""已有非空内容保留,只补空格。"""
out: Dict[str, Any] = dict(saved or {})
for key, value in (suggested or {}).items():
if value is None or str(value).strip() == "":
continue
current = out.get(key)
if current is None or str(current).strip() == "":
out[key] = value
return out
def _latest_task_params(tasks: List[Dict[str, Any]], predicate) -> List[str]:
matched = [task for task in tasks if predicate(task)]
if not matched:
return []
chosen = max(matched, key=lambda task: task["stamp"])
return list(chosen["values"])
def _cd34_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
flow_tasks = [task for task in tasks if is_cd34_flow_item(task["item_name"])]
if not flow_tasks:
return {}
chosen = max(flow_tasks, key=lambda task: task["stamp"])
out: Dict[str, str] = {}
for param_name, param_value in chosen["params"]:
field = match_cd34_param(param_name)
text = (param_value or "").strip()
if field and text and field not in out:
out[field] = text
return out
def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
"""生产过程质检:内毒素、真菌、支原体。只认参数里的阴/阳,任务结论「通过」不算。"""
out: Dict[str, str] = {}
for field in ("endotoxin_test", "fungus_test", "mycoplasma_test"):
values = _latest_task_params(
tasks, lambda task, field=field: match_process_qc_field(task["item_name"]) == field
)
qual = _first_negative_positive(values)
if qual:
out[field] = qual
return out
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None:
"""只补母亲血液空项。共享任务里的脐血不参与。"""
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES)
for key, value in shared.items():
suggested.setdefault(key, value)
def build_suggested(
*,
sample_type_key: str,
collection_time: Any = None,
reception_time: Any = None,
receive_temperature: Any = None,
enriched_blood_weight_g: Any = None,
cryopreservation_volume_ml: Any = None,
storage_total_tubes: Any = None,
available_cell_count_1e7: Any = None,
gate_tasks: Optional[List[Dict[str, Any]]] = None,
process_tasks: Optional[List[Dict[str, Any]]] = None,
shared_mother_tasks: Optional[List[Dict[str, Any]]] = None,
latest_freeze_date: Any = None,
) -> Dict[str, str]:
suggested: Dict[str, str] = {}
collected = format_datetime(collection_time)
if collected:
suggested["collection_time"] = collected
received = format_datetime(reception_time)
if received:
suggested["receive_time"] = received
freeze = format_date(latest_freeze_date)
if sample_type_key == "perinatal_hsc":
volume = sum_cryo_volume(enriched_blood_weight_g, cryopreservation_volume_ml)
if volume:
suggested["cryo_volume_ml"] = volume
suggested.update(_cd34_from_tasks(gate_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or []))
suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_comment", "合格")
if freeze:
suggested["storage_date"] = freeze
suggested.update(_FIXED_COMMON)
elif sample_type_key == "uc_msc":
temp = ("" if receive_temperature is None else str(receive_temperature)).strip()
if temp:
suggested["transport_temp_c"] = temp
tubes = format_number(storage_total_tubes)
if tubes:
suggested["storage_tube_count"] = tubes
count = format_number(available_cell_count_1e7)
if count:
suggested["available_cell_count_1e7"] = count
suggested.update(_process_qual_from_tasks(process_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES))
suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_judgment", "合格")
if freeze:
suggested["storage_date_p2"] = freeze
suggested.update(_FIXED_COMMON)
suggested.update(_FIXED_UC_MSC)
if sample_type_key in ("perinatal_hsc", "uc_msc"):
_fill_shared_mother(suggested, shared_mother_tasks)
return {key: value for key, value in suggested.items() if str(value).strip()}
def _stamp(completed: Any, updated: Any) -> datetime:
if isinstance(completed, datetime):
return completed
if isinstance(updated, datetime):
return updated
return datetime.min
def _load_qc_tasks(db: Session, tenant_no: str, sample_code: str, trigger_source: str) -> List[Dict[str, Any]]:
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 == sample_code,
BizQcWorkOrder.del_flag == 0,
BizQcWorkOrder.trigger_source == trigger_source,
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 _sibling_sample_codes(db: Session, tenant_no: str, sample_code: str) -> List[str]:
"""同一采集协议下的其他样本。母血通常只做在其中一份上。"""
owner = (
db.query(BizProtocolSample.collection_protocol_id)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == sample_code,
BizProtocolSample.del_flag == 0,
)
.first()
)
protocol_id = (owner.collection_protocol_id or "").strip() if owner else ""
if not protocol_id:
return []
rows = (
db.query(BizProtocolSample.sample_code)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.sample_code != sample_code,
BizProtocolSample.del_flag == 0,
)
.all()
)
codes = []
for row in rows:
code = (row.sample_code or "").strip()
if code and code not in codes:
codes.append(code)
return codes
def _load_shared_mother_tasks(db: Session, tenant_no: str, sample_code: str) -> List[Dict[str, Any]]:
tasks: List[Dict[str, Any]] = []
for code in _sibling_sample_codes(db, tenant_no, sample_code):
tasks.extend(
task
for task in _load_qc_tasks(db, tenant_no, code, "reception")
if task.get("batch_kind") == "母血"
)
return tasks
def _latest_freeze_date(db: Session, tenant_no: str, sample_code: str) -> Optional[datetime]:
output_dates = (
db.query(BizCellProduct.freeze_date)
.join(BizProductionTaskOutput, BizProductionTaskOutput.cell_product_id == BizCellProduct.id)
.join(BizProductionTask, BizProductionTask.id == BizProductionTaskOutput.task_id)
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
.filter(
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.sample_code == sample_code,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_status != "yzf",
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
BizProductionTaskOutput.tenant_no == tenant_no,
BizProductionTaskOutput.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_status != "voided",
BizCellProduct.freeze_date.isnot(None),
)
.all()
)
linked_dates = (
db.query(BizCellProduct.freeze_date)
.join(BizProductionTask, BizProductionTask.id == BizCellProduct.production_task_id)
.join(BizProductionWorkOrder, BizProductionWorkOrder.id == BizProductionTask.work_order_id)
.filter(
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.sample_code == sample_code,
BizProductionWorkOrder.del_flag == 0,
BizProductionWorkOrder.work_order_status != "yzf",
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
BizCellProduct.tenant_no == tenant_no,
BizCellProduct.del_flag == 0,
BizCellProduct.product_status != "voided",
BizCellProduct.freeze_date.isnot(None),
)
.all()
)
dates = [row.freeze_date for row in output_dates + linked_dates if isinstance(row.freeze_date, datetime)]
if not dates:
return None
return max(dates)
def collect_prefill(db: Session, tenant_no: str, sample_code: str, sample_type_key: Optional[str]) -> Dict[str, str]:
if sample_type_key not in ("perinatal_hsc", "uc_msc"):
return {}
code = (sample_code or "").strip()
if not code:
return {}
collection = (
db.query(BizProtocolCollectionInfo.sample_collect_time)
.join(
BizProtocolSample,
BizProtocolSample.collection_protocol_id == BizProtocolCollectionInfo.collection_protocol_id,
)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == code,
BizProtocolSample.del_flag == 0,
BizProtocolCollectionInfo.tenant_no == tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
)
.first()
)
collection_time = collection.sample_collect_time if collection else None
reception = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.sample_code == code,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == "cell_storage",
BizSampleReception.accept_conclusion == "js",
)
.order_by(BizSampleReception.reception_time.desc())
.first()
)
production = None
if reception is not None:
production = (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.reception_id == reception.id,
BizSampleProductionReception.del_flag == 0,
)
.first()
)
return build_suggested(
sample_type_key=sample_type_key,
collection_time=collection_time,
reception_time=None if reception is None else reception.reception_time,
receive_temperature=None if reception is None else reception.receive_temperature,
enriched_blood_weight_g=None if production is None else production.enriched_blood_weight_g,
cryopreservation_volume_ml=None if production is None else production.cryopreservation_volume_ml,
storage_total_tubes=None if production is None else production.storage_total_tubes,
available_cell_count_1e7=None if production is None else production.available_cell_count_1e7,
gate_tasks=_load_qc_tasks(db, tenant_no, code, "reception"),
process_tasks=_load_qc_tasks(db, tenant_no, code, "production"),
shared_mother_tasks=_load_shared_mother_tasks(db, tenant_no, code),
latest_freeze_date=_latest_freeze_date(db, tenant_no, code),
)