Files
zkxw/backend/app/services/lab_sample_processing.py
T
zlwuandCursor 5698a9dfc4 fix(lab-sample): 台账改聚合真实市场生产质检数据
去掉 Excel 演示独有行,源表字段优先于补充表;按协议样本与科研捐献接收关联已发生业务数据,并修复导出分页超限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:41:26 +08:00

721 lines
25 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 types import SimpleNamespace
from sqlalchemy import or_, text
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()
}
# 仅聚合真实协议样本;补充表只挂到已有样本上,不再单独展示 Excel 演示行
codes = [s.sample_code for s, *_ in filtered if s.sample_code]
sample_ids = [s.id for s, *_ in filtered if s.id]
receptions_by_sample_id: Dict[str, BizSampleReception] = {}
receptions_by_code: Dict[str, BizSampleReception] = {}
productions: Dict[str, BizSampleProductionReception] = {}
if sample_ids or codes:
recv_filters = []
if sample_ids:
recv_filters.append(BizSampleReception.protocol_sample_id.in_(sample_ids))
if codes:
recv_filters.append(BizSampleReception.sample_code.in_(codes))
for r in (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
or_(*recv_filters) if recv_filters else False,
)
.order_by(BizSampleReception.reception_time.desc())
.all()
):
if r.protocol_sample_id:
receptions_by_sample_id.setdefault(r.protocol_sample_id, r)
if r.sample_code:
receptions_by_code.setdefault(r.sample_code, r)
if receptions_by_sample_id or receptions_by_code:
rids = list(
{
*(r.id for r in receptions_by_sample_id.values()),
*(r.id for r in receptions_by_code.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_by_sample_id.get(sample.id) or receptions_by_code.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,
)
# 单个核:业务已发生数据主要在科研捐献接收(lab_storage_code=UBMC*),无细胞储存协议样本时仍需展示
if dataset == DATASET_UBMC:
ubmc_recs = (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.lab_storage_code.like("UBMC%"),
)
.order_by(BizSampleReception.reception_time.desc())
.all()
)
mother_by_donation: Dict[str, str] = {}
donation_ids = sorted(
{r.research_donation_id for r in ubmc_recs if r.research_donation_id}
)
if donation_ids:
rows = db.execute(
text(
"SELECT research_donation_id, mother_name FROM biz_research_donation_party "
"WHERE del_flag=0 AND tenant_no=:tenant_no AND research_donation_id IN ("
+ ",".join(f":d{i}" for i in range(len(donation_ids)))
+ ")"
),
{
"tenant_no": tenant_no,
**{f"d{i}": did for i, did in enumerate(donation_ids)},
},
).fetchall()
for did, mother in rows:
if did and mother and did not in mother_by_donation:
mother_by_donation[str(did)] = mother
missing_rids = [
r.id
for r in ubmc_recs
if r.id and r.id not in productions
]
if missing_rids:
for p in (
db.query(BizSampleProductionReception)
.filter(
BizSampleProductionReception.tenant_no == tenant_no,
BizSampleProductionReception.del_flag == 0,
BizSampleProductionReception.reception_id.in_(missing_rids),
)
.order_by(BizSampleProductionReception.process_time.desc())
.all()
):
productions.setdefault(p.reception_id, p)
for reception in ubmc_recs:
code = (reception.lab_storage_code or reception.sample_code or "").strip()
if not code or code in built_map:
continue
production = productions.get(reception.id)
extra = _parse_extra(extras[code].payload_json if code in extras else None)
mother = mother_by_donation.get(str(reception.research_donation_id or ""))
fake_sample = SimpleNamespace(
id=reception.donation_sample_id or reception.id,
sample_code=code,
collection_protocol_id=None,
)
fake_party = SimpleNamespace(mother_name=mother) if mother else None
built_map[code] = _build_row(
dataset=dataset,
sample=fake_sample,
type_name="脐血单个核细胞",
party=fake_party,
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)
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