"""实验室样本处理数据 — 聚合读取、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