按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。 Co-authored-by: Cursor <cursoragent@cursor.com>
213 lines
6.1 KiB
Python
213 lines
6.1 KiB
Python
"""样本储存证质检信息 — 类型键与 payload 工具。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
|
|
|
SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
|
|
SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
|
|
SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
|
|
SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
|
|
|
|
ACL_SCOPE_QC = "qc"
|
|
ACL_SCOPE_PRODUCTION = "production"
|
|
ACL_SCOPES = frozenset({ACL_SCOPE_QC, ACL_SCOPE_PRODUCTION})
|
|
ACL_CONFIG_MENU = "cell-storage:sample-info:storage-cert-qc-acl"
|
|
|
|
_NAME_TO_KEY = {
|
|
SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
|
SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
|
|
}
|
|
|
|
# 与 web-admin STORAGE_CERT_QC_GROUPS 对齐的合法 payload 键(两类型并集)。
|
|
ALL_STORAGE_CERT_QC_FIELD_KEYS = frozenset(
|
|
{
|
|
"collection_time",
|
|
"transport_temp_c",
|
|
"receive_time",
|
|
"cryo_volume_ml",
|
|
"tnc_1e8",
|
|
"cd34_count_1e6",
|
|
"cd34_cd45dim_1e5",
|
|
"viability_pct",
|
|
"bacteria_test",
|
|
"hbsag",
|
|
"anti_hcv",
|
|
"anti_tp",
|
|
"anti_hiv",
|
|
"cmv_igm",
|
|
"mother_hbsag",
|
|
"mother_hbv_dna",
|
|
"mother_anti_hcv",
|
|
"mother_hcv_rna",
|
|
"mother_anti_tp",
|
|
"mother_cmv_igm",
|
|
"mother_anti_hiv",
|
|
"mother_hiv1_rna",
|
|
"hla_typing",
|
|
"storage_date",
|
|
"storage_years",
|
|
"result_comment",
|
|
"report_issue_date",
|
|
"storage_tube_count",
|
|
"available_cell_type",
|
|
"available_cell_count_1e7",
|
|
"available_viability_pct",
|
|
"endotoxin_test",
|
|
"fungus_test",
|
|
"mycoplasma_test",
|
|
"available_cell_morphology",
|
|
"available_immunophenotype",
|
|
"result_judgment",
|
|
"storage_date_p2",
|
|
"freeze_method",
|
|
"storage_temp",
|
|
}
|
|
)
|
|
|
|
# 脐带间充质储存证不收录母亲血液三项核酸检测。
|
|
UC_MSC_EXCLUDED_PAYLOAD_KEYS = frozenset(
|
|
{
|
|
"mother_hbv_dna",
|
|
"mother_hcv_rna",
|
|
"mother_hiv1_rna",
|
|
}
|
|
)
|
|
|
|
|
|
def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
|
|
name = (sample_type_name or "").strip()
|
|
return _NAME_TO_KEY.get(name)
|
|
|
|
|
|
def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
|
|
return resolve_sample_type_key(sample_type_name) is not None
|
|
|
|
|
|
def legal_field_keys_for_type(sample_type_key: Optional[str]) -> Set[str]:
|
|
keys = set(ALL_STORAGE_CERT_QC_FIELD_KEYS)
|
|
if sample_type_key == SAMPLE_TYPE_KEY_UC_MSC:
|
|
keys -= UC_MSC_EXCLUDED_PAYLOAD_KEYS
|
|
return keys
|
|
|
|
|
|
def normalize_payload(payload: Any) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("payload 必须为对象")
|
|
out: Dict[str, Any] = {}
|
|
for k, v in payload.items():
|
|
if not isinstance(k, str):
|
|
continue
|
|
key = k.strip()
|
|
if not key:
|
|
continue
|
|
out[key] = v
|
|
return out
|
|
|
|
|
|
def sanitize_payload_for_type(
|
|
payload: Dict[str, Any],
|
|
sample_type_key: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
"""按样本类型去掉不应展示/落库的键。"""
|
|
if sample_type_key != SAMPLE_TYPE_KEY_UC_MSC:
|
|
return payload
|
|
return {key: value for key, value in payload.items() if key not in UC_MSC_EXCLUDED_PAYLOAD_KEYS}
|
|
|
|
|
|
def dumps_payload(payload: Dict[str, Any]) -> str:
|
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
data = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
return data
|
|
|
|
|
|
def dumps_field_keys(keys: Iterable[str]) -> str:
|
|
cleaned = sorted({str(k).strip() for k in keys if str(k).strip()})
|
|
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def loads_field_keys(raw: Optional[str]) -> List[str]:
|
|
if not raw:
|
|
return []
|
|
try:
|
|
data = json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
return []
|
|
if not isinstance(data, list):
|
|
return []
|
|
out: List[str] = []
|
|
seen = set()
|
|
for item in data:
|
|
key = str(item or "").strip()
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(key)
|
|
return out
|
|
|
|
|
|
def _payload_value_text(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
return str(value).strip()
|
|
|
|
|
|
def merge_payload_with_acl(
|
|
*,
|
|
existing: Dict[str, Any],
|
|
incoming: Dict[str, Any],
|
|
editable_keys: Iterable[str],
|
|
sample_type_key: Optional[str],
|
|
) -> Dict[str, Any]:
|
|
"""仅合并有权字段;无权字段若相对原值变化则拒绝。"""
|
|
allowed = {str(k).strip() for k in editable_keys if str(k).strip()}
|
|
if not allowed:
|
|
raise PermissionError("无字段编辑权限")
|
|
clean_incoming = sanitize_payload_for_type(normalize_payload(incoming), sample_type_key)
|
|
base = sanitize_payload_for_type(dict(existing or {}), sample_type_key)
|
|
for key, value in clean_incoming.items():
|
|
if key in allowed:
|
|
continue
|
|
if _payload_value_text(base.get(key)) != _payload_value_text(value):
|
|
raise PermissionError(f"无权修改字段: {key}")
|
|
merged = dict(base)
|
|
for key in allowed:
|
|
if key in clean_incoming:
|
|
merged[key] = clean_incoming[key]
|
|
return sanitize_payload_for_type(merged, sample_type_key)
|
|
|
|
|
|
def empty_detail(
|
|
*,
|
|
sample_code: str,
|
|
lab_flow_code: str = "",
|
|
protocol_code: str = "",
|
|
mother_name: str = "",
|
|
sample_type_name: str = "",
|
|
sample_type_key: Optional[str] = None,
|
|
collection_protocol_id: str = "",
|
|
qc_work_order_id: str = "",
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"sampleCode": sample_code,
|
|
"labFlowCode": lab_flow_code or "",
|
|
"protocolCode": protocol_code or "",
|
|
"motherName": mother_name or "",
|
|
"sampleTypeName": sample_type_name or "",
|
|
"sampleTypeKey": sample_type_key or resolve_sample_type_key(sample_type_name),
|
|
"collectionProtocolId": collection_protocol_id or "",
|
|
"qcWorkOrderId": qc_work_order_id or "",
|
|
"filledFlag": False,
|
|
"payload": {},
|
|
}
|