"""科研捐赠管理 CRUD(捐赠单 / 审核 / 状态变更 / 转换)""" from __future__ import annotations import math from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date from app.crud.base import camel_to_snake from app.crud.biz_no_generator import allocate_next_biz_no from app.crud.collection_protocol import ( attachments_to_urls, urls_to_attachment_list, validate_sample_codes_not_used, _format_sample_collect_summary, _load_sample_type_name_map, _load_site_name_map, ) from app.crud.protocol_donation_convert import ( assert_biz_no_available, create_convert_link, get_convert_partner, has_active_convert_link, upsert_convert_link_for_to_storage, ) from app.utils.sample_special import ( apply_sample_special_fields, sample_special_to_dict, validate_sample_special_raw, ) from app.models.models import ( BizCollectionProtocol, BizCollectionRegion, BizCollectionHospital, BizItemUnit, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolSample, BizProtocolSampleTransport, BizQcWorkOrder, BizSampleReception, SysUser, ) from app.models.research_donation import ( BizResearchDonation, BizResearchDonationAudit, BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample, BizResearchDonationSampleTransport, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def total_pages(total: int, page_size: int) -> int: if total <= 0: return 0 return int(math.ceil(total / float(page_size))) _DONATION_SAMPLE_CODE_PREFIX = "KYJZ" _DONATION_SAMPLE_CODE_SEQ_LEN = 4 def _donation_sample_code_seq(code: str, *, date_str: str) -> Optional[int]: """解析 KYJZ+YYYYMM+序号 中的序号;不匹配则返回 None。""" prefix = f"{_DONATION_SAMPLE_CODE_PREFIX}{date_str}" c = (code or "").strip() if not c.startswith(prefix): return None suffix = c[len(prefix) :] if len(suffix) != _DONATION_SAMPLE_CODE_SEQ_LEN or not suffix.isdigit(): return None return int(suffix) def next_donation_sample_code( db: Session, *, tenant_no: str, extra_codes: Optional[List[str]] = None, ) -> str: """生成捐赠样本编码:KYJZ + YYYYMM + 4 位递增序号(0001 起)。""" date_str = datetime.now().strftime("%Y%m") prefix = f"{_DONATION_SAMPLE_CODE_PREFIX}{date_str}" max_seq = 0 def bump(code: Optional[str]) -> None: nonlocal max_seq seq = _donation_sample_code_seq(code or "", date_str=date_str) if seq is not None: max_seq = max(max_seq, seq) donation_rows = ( db.query(BizResearchDonationSample.sample_code) .filter( BizResearchDonationSample.tenant_no == tenant_no, BizResearchDonationSample.del_flag == 0, BizResearchDonationSample.sample_code.like(f"{prefix}%"), ) .all() ) for row in donation_rows: bump(row[0]) protocol_rows = ( db.query(BizProtocolSample.sample_code) .filter( BizProtocolSample.tenant_no == tenant_no, BizProtocolSample.del_flag == 0, BizProtocolSample.sample_code.like(f"{prefix}%"), ) .all() ) for row in protocol_rows: bump(row[0]) from app.models.models import BizSampleCode pool_rows = ( db.query(BizSampleCode.sample_code) .filter( BizSampleCode.tenant_no == tenant_no, BizSampleCode.del_flag == 0, BizSampleCode.sample_code.like(f"{prefix}%"), ) .all() ) for row in pool_rows: bump(row[0]) for code in extra_codes or []: bump(code) return f"{prefix}{str(max_seq + 1).zfill(_DONATION_SAMPLE_CODE_SEQ_LEN)}" def _fmt_dt(t: Optional[datetime]) -> str: return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str: val = (value or "").strip() if len(val) == 10: return f"{val} 23:59:59" if end_of_day else f"{val} 00:00:00" return val def _dec(v: Any) -> Optional[float]: if v is None: return None if isinstance(v, Decimal): return float(v) return float(v) def _optional_decimal(v: Any) -> Optional[Decimal]: if v is None or v == "": return None return Decimal(str(v)) _SNAKE_ALIASES = { "apgar1min": "apgar_1min", "apgar5min": "apgar_5min", } def _normalize_fields(fields: Optional[dict]) -> dict: """将前端 camelCase 字段名转为数据库 snake_case。""" if not fields: return {} out: Dict[str, Any] = {} for k, v in fields.items(): sk = _SNAKE_ALIASES.get(k) or camel_to_snake(k) out[sk] = v return out def _merge_comma_separated_urls(*parts: Optional[str]) -> Optional[str]: """合并逗号分隔的附件 URL,去重并保持顺序。""" seen: set[str] = set() urls: List[str] = [] for part in parts: if not part: continue for url in str(part).split(","): u = url.strip() if not u or u in seen: continue seen.add(u) urls.append(u) return ",".join(urls) if urls else None def _resolve_donation_payload(raw: dict) -> None: """解析保存载荷:附件列表 → URL 字段;party/collection 别名。""" if raw.get("consent_attachments") is not None: raw["consent_form_urls"] = attachments_to_urls(raw["consent_attachments"]) or None elif (raw.get("consent_form_urls") or "").strip(): raw["consent_form_urls"] = (raw["consent_form_urls"] or "").strip() if raw.get("other_attachments") is not None: raw["attachment_urls"] = attachments_to_urls(raw["other_attachments"]) or None elif (raw.get("attachment_urls") or "").strip(): raw["attachment_urls"] = (raw["attachment_urls"] or "").strip() if raw.get("infectious_disease_report_attachments") is not None: raw["infectious_disease_report_urls"] = ( attachments_to_urls(raw["infectious_disease_report_attachments"]) or None ) elif (raw.get("infectious_disease_report_urls") or "").strip(): raw["infectious_disease_report_urls"] = ( (raw["infectious_disease_report_urls"] or "").strip() ) if not raw.get("party") and raw.get("party_customer"): raw["party"] = raw["party_customer"] if not raw.get("collection") and raw.get("collection_info"): raw["collection"] = raw["collection_info"] def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: ids = list({x for x in user_ids if x}) if not ids: return {} rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all() return {u.id: u.nick_name or "" for u in rows} def _load_unit_name_map(db: Session, samples: List[BizResearchDonationSample]) -> Dict[str, str]: unit_ids = list({s.unit_id for s in samples if s.unit_id}) if not unit_ids: return {} unit_map: Dict[str, str] = {} for u in ( db.query(BizItemUnit) .filter(BizItemUnit.id.in_(unit_ids), BizItemUnit.del_flag == 0) .all() ): unit_map[u.id] = u.unit_name or "" return unit_map # --------------------------------------------------------------------------- # ORM → dict serializers # --------------------------------------------------------------------------- def _donation_to_dict( d: BizResearchDonation, db: Optional[Session] = None, tenant_no: Optional[str] = None, ) -> Dict[str, Any]: tn = tenant_no or d.tenant_no return { "id": d.id, "donationNo": d.donation_no or "", "donationStatus": d.donation_status or "", "donationDate": (d.donation_date or "").strip() if d.donation_date else "", "sourceType": d.source_type or "direct", "sourceProtocolId": d.source_protocol_id or "", "consentFormUrls": d.consent_form_urls or "", "attachmentUrls": d.attachment_urls or "", "infectiousDiseaseReportUrls": d.infectious_disease_report_urls or "", "consentAttachmentFiles": urls_to_attachment_list(d.consent_form_urls, db, tn) if db else [], "attachmentFiles": urls_to_attachment_list(d.attachment_urls, db, tn) if db else [], "infectiousDiseaseReportAttachmentFiles": ( urls_to_attachment_list(d.infectious_disease_report_urls, db, tn) if db else [] ), "collectorUserId": d.collector_user_id or "", "effectiveTime": _fmt_dt(d.effective_time), "effectiveDesc": d.effective_desc or "", "expireTime": _fmt_dt(d.expire_time), "expireDesc": d.expire_desc or "", "voidTime": _fmt_dt(d.void_time), "voidDesc": d.void_desc or "", "voidType": d.void_type or "", "toStorageTime": _fmt_dt(d.to_storage_time), "toStorageDesc": d.to_storage_desc or "", "tenantNo": d.tenant_no, "orderNum": d.order_num if d.order_num is not None else 0, "remark": d.remark or "", "createTime": _fmt_dt(d.create_time), "updateTime": _fmt_dt(d.update_time), } def _audit_to_dict( a: BizResearchDonationAudit, *, donation_no: str = "", auditor_user_name: str = "", ) -> Dict[str, Any]: return { "id": a.id, "researchDonationId": a.research_donation_id, "donationNo": donation_no, "auditorUserId": a.auditor_user_id or "", "auditorUserName": auditor_user_name or "", "auditRemark": a.audit_remark or "", "auditStatus": a.audit_status or "", "tenantNo": a.tenant_no, "orderNum": a.order_num if a.order_num is not None else 0, "remark": a.remark or "", "createTime": _fmt_dt(a.create_time), "updateTime": _fmt_dt(a.update_time), } def _party_to_dict(p: BizResearchDonationParty) -> Dict[str, Any]: return { "id": p.id, "researchDonationId": p.research_donation_id, "customerId": p.customer_id or "", "motherName": p.mother_name or "", "motherPhone": p.mother_phone or "", "motherIdCard": p.mother_id_card or "", "motherBirthYm": (p.mother_birth_ym or "").strip() if p.mother_birth_ym else "", "motherEthnic": p.mother_ethnic or "", "motherEmail": p.mother_email or "", "motherAddress": p.mother_address or "", "fatherName": p.father_name or "", "fatherPhone": p.father_phone or "", "fatherIdCard": p.father_id_card or "", "newbornName": p.newborn_name or "", "newbornIdCard": p.newborn_id_card or "", "expectedDeliveryDate": format_date(p.expected_delivery_date), "newbornDeliveryTime": _fmt_dt(p.newborn_delivery_time), "newbornSex": p.newborn_sex or "", "deliveryMode": p.delivery_mode or "", "birthOrder": p.birth_order, "newbornWeightGram": p.newborn_weight_gram, "apgar1min": _dec(p.apgar_1min), "apgar5min": _dec(p.apgar_5min), "collectorUserId": p.collector_user_id or "", "tenantNo": p.tenant_no, "orderNum": p.order_num if p.order_num is not None else 0, "remark": p.remark or "", "createTime": _fmt_dt(p.create_time), "updateTime": _fmt_dt(p.update_time), } def _collection_to_dict(c: BizResearchDonationCollection) -> Dict[str, Any]: return { "id": c.id, "researchDonationId": c.research_donation_id, "regionId": c.region_id or "", "hospitalId": c.hospital_id or "", "medicalStaff": c.medical_staff or "", "salesUserId": c.sales_user_id or "", "sampleCollectTime": _fmt_dt(c.sample_collect_time), "maternalBloodCollectTime": _fmt_dt(c.maternal_blood_collect_time), "tenantNo": c.tenant_no, "orderNum": c.order_num if c.order_num is not None else 0, "remark": c.remark or "", "createTime": _fmt_dt(c.create_time), "updateTime": _fmt_dt(c.update_time), } def _sample_to_dict(s: BizResearchDonationSample) -> Dict[str, Any]: return { "id": s.id, "researchDonationId": s.research_donation_id, "sampleTypeId": s.sample_type_id or "", "sampleCodeId": s.sample_code_id or "", "sampleCode": s.sample_code or "", "collectAmount": _dec(s.collect_amount), "unitId": s.unit_id or "", "storageYears": s.storage_years, "sampleStatus": (s.sample_status or "").strip() if (s.sample_status or "").strip() else "", "storageInDate": (s.storage_in_date or "").strip() if s.storage_in_date else "", "detectStartDate": (s.detect_start_date or "").strip() if s.detect_start_date else "", "voidDate": (s.void_date or "").strip() if s.void_date else "", "storageExpireDate": (s.storage_expire_date or "").strip() if s.storage_expire_date else "", **sample_special_to_dict(s), "tenantNo": s.tenant_no, "orderNum": s.order_num if s.order_num is not None else 0, "remark": s.remark or "", "createTime": _fmt_dt(s.create_time), "updateTime": _fmt_dt(s.update_time), } def _transport_to_dict(t: BizResearchDonationSampleTransport) -> Dict[str, Any]: return { "id": t.id, "researchDonationId": t.research_donation_id, "sampleId": t.sample_id, "transportType": t.transport_type or "", "expressCompanyId": t.express_company_id or "", "waybillNo": t.waybill_no or "", "destSiteId": t.dest_site_id or "", "expressFee": _dec(t.express_fee), "etaTime": _fmt_dt(t.eta_time), "carrierName": t.carrier_name or "", "carrierPhone": t.carrier_phone or "", "remark": t.remark or "", "orderNum": t.order_num if t.order_num is not None else 0, "createTime": _fmt_dt(t.create_time), "updateTime": _fmt_dt(t.update_time), } # --------------------------------------------------------------------------- # Sub-resource queries # --------------------------------------------------------------------------- def get_party( db: Session, research_donation_id: str, tenant_no: str ) -> Optional[BizResearchDonationParty]: return ( db.query(BizResearchDonationParty) .filter( and_( BizResearchDonationParty.research_donation_id == research_donation_id, BizResearchDonationParty.tenant_no == tenant_no, BizResearchDonationParty.del_flag == 0, ) ) .first() ) def get_collection( db: Session, research_donation_id: str, tenant_no: str ) -> Optional[BizResearchDonationCollection]: return ( db.query(BizResearchDonationCollection) .filter( and_( BizResearchDonationCollection.research_donation_id == research_donation_id, BizResearchDonationCollection.tenant_no == tenant_no, BizResearchDonationCollection.del_flag == 0, ) ) .first() ) def list_samples( db: Session, research_donation_id: str, tenant_no: str ) -> List[BizResearchDonationSample]: return ( db.query(BizResearchDonationSample) .filter( and_( BizResearchDonationSample.research_donation_id == research_donation_id, BizResearchDonationSample.tenant_no == tenant_no, BizResearchDonationSample.del_flag == 0, ) ) .order_by( BizResearchDonationSample.order_num.asc(), BizResearchDonationSample.create_time.asc(), ) .all() ) def list_sample_transports( db: Session, research_donation_id: str, tenant_no: str ) -> List[BizResearchDonationSampleTransport]: return ( db.query(BizResearchDonationSampleTransport) .filter( and_( BizResearchDonationSampleTransport.research_donation_id == research_donation_id, BizResearchDonationSampleTransport.tenant_no == tenant_no, BizResearchDonationSampleTransport.del_flag == 0, ) ) .order_by( BizResearchDonationSampleTransport.order_num.asc(), BizResearchDonationSampleTransport.create_time.asc(), ) .all() ) # --------------------------------------------------------------------------- # 1. list_donations # --------------------------------------------------------------------------- def list_donations( db: Session, *, tenant_no: str, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, donation_status: Optional[str] = None, create_time_start: Optional[str] = None, create_time_end: Optional[str] = None, sample_collect_time_start: Optional[str] = None, sample_collect_time_end: Optional[str] = None, user_info=None, ) -> Dict[str, Any]: def _base(): q = ( db.query(BizResearchDonation) .filter(BizResearchDonation.del_flag == 0) ) if tenant_no: q = q.filter(BizResearchDonation.tenant_no == tenant_no) if donation_status: q = q.filter(BizResearchDonation.donation_status == donation_status.strip()) if create_time_start: q = q.filter( BizResearchDonation.create_time >= _normalize_date_bound(create_time_start) ) if create_time_end: q = q.filter( BizResearchDonation.create_time <= _normalize_date_bound(create_time_end, end_of_day=True) ) if sample_collect_time_start or sample_collect_time_end: coll_q = db.query(BizResearchDonationCollection.research_donation_id).filter( BizResearchDonationCollection.del_flag == 0, ) if sample_collect_time_start: coll_q = coll_q.filter( BizResearchDonationCollection.sample_collect_time >= _normalize_date_bound(sample_collect_time_start) ) if sample_collect_time_end: coll_q = coll_q.filter( BizResearchDonationCollection.sample_collect_time <= _normalize_date_bound(sample_collect_time_end, end_of_day=True) ) q = q.filter(BizResearchDonation.id.in_(coll_q)) kw = (keyword or "").strip() if kw: like = f"%{kw}%" party_match = ( db.query(BizResearchDonationParty.research_donation_id) .filter( BizResearchDonationParty.del_flag == 0, BizResearchDonationParty.mother_name.like(like), ) .subquery() ) kw_conds = [ BizResearchDonation.id.in_(party_match), ] if tenant_no: from app.crud import sample_reception as reception_svc donation_ids = reception_svc.donation_ids_matching_lab_storage_keyword( db, tenant_no, kw ) if donation_ids: kw_conds.append(BizResearchDonation.id.in_(donation_ids)) q = q.filter(or_(*kw_conds)) if user_info: from app.core.data_scope import apply_data_scope q = apply_data_scope(q, BizResearchDonation, user_info, db, tenant_no) return q total = ( _base() .order_by(None) .with_entities(func.count(BizResearchDonation.id)) .scalar() ) or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( _base() .order_by(BizResearchDonation.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) # 批量加载 party / collection 子表,避免 N+1 donation_ids = [r.id for r in rows] party_map: Dict[str, Dict[str, Any]] = {} collection_map: Dict[str, Dict[str, Any]] = {} if donation_ids: party_rows = ( db.query(BizResearchDonationParty) .filter( BizResearchDonationParty.research_donation_id.in_(donation_ids), BizResearchDonationParty.del_flag == 0, ) .all() ) for p in party_rows: party_map[p.research_donation_id] = _party_to_dict(p) collection_rows = ( db.query(BizResearchDonationCollection) .filter( BizResearchDonationCollection.research_donation_id.in_(donation_ids), BizResearchDonationCollection.del_flag == 0, ) .all() ) for c in collection_rows: collection_map[c.research_donation_id] = _collection_to_dict(c) # 批量解析地区 / 医院名称 region_ids = list( {collection_map.get(did, {}).get("regionId", "") for did in donation_ids} - {""} ) hospital_ids = list( {collection_map.get(did, {}).get("hospitalId", "") for did in donation_ids} - {""} ) region_name_map: Dict[str, str] = {} hospital_name_map: Dict[str, str] = {} if region_ids: for reg in ( db.query(BizCollectionRegion.id, BizCollectionRegion.region_name) .filter(BizCollectionRegion.id.in_(region_ids)) .all() ): region_name_map[reg.id] = reg.region_name or "" if hospital_ids: for hosp in ( db.query(BizCollectionHospital.id, BizCollectionHospital.hospital_name) .filter(BizCollectionHospital.id.in_(hospital_ids)) .all() ): hospital_name_map[hosp.id] = hosp.hospital_name or "" # 批量解析采集专员 / 销售人员名称 sales_user_ids = list( {collection_map.get(did, {}).get("salesUserId", "") for did in donation_ids} - {""} ) collector_ids = list({r.collector_user_id for r in rows if r.collector_user_id}) user_name_map = _user_nick_name_map(db, list(set(collector_ids + sales_user_ids))) # 批量加载捐赠样本(列表展示样本类型、样本编码、采集情况) sample_map: Dict[str, List[BizResearchDonationSample]] = {} sample_type_name_map: Dict[str, str] = {} if donation_ids: s_rows = ( db.query(BizResearchDonationSample) .filter( BizResearchDonationSample.research_donation_id.in_(donation_ids), BizResearchDonationSample.del_flag == 0, ) .order_by( BizResearchDonationSample.order_num.asc(), BizResearchDonationSample.create_time.asc(), ) .all() ) sample_type_name_map = _load_sample_type_name_map(db, s_rows) for s in s_rows: sample_map.setdefault(s.research_donation_id, []).append(s) all_sample_codes: List[str] = [] for samples in sample_map.values(): for s in samples: c = (s.sample_code or "").strip() if c: all_sample_codes.append(c) lab_code_map: Dict[str, str] = {} reception_summary_map: Dict[str, Dict[str, str]] = {} qc_status_map: Dict[str, Dict[str, str]] = {} from app.services.protocol_sample_list_summary import ( build_sample_item_reception_qc_fields, get_list_qc_status_map_by_sample_codes, get_list_reception_summary_map_by_sample_codes, ) if tenant_no and all_sample_codes: from app.crud import sample_reception as reception_svc lab_code_map = reception_svc.get_lab_storage_code_map_by_sample_codes( db, tenant_no, all_sample_codes, reception_source="research_donation" ) reception_summary_map = get_list_reception_summary_map_by_sample_codes( db, tenant_no, all_sample_codes, reception_source="research_donation" ) qc_status_map = get_list_qc_status_map_by_sample_codes( db, tenant_no, all_sample_codes, reception_source="research_donation" ) result = [] for r in rows: d = _donation_to_dict(r, db, tenant_no) d["collectorUserName"] = user_name_map.get(r.collector_user_id or "", "") pc = party_map.get(r.id) if pc: d["motherName"] = pc.get("motherName", "") d["motherPhone"] = pc.get("motherPhone", "") d["motherIdCard"] = pc.get("motherIdCard", "") d["fatherName"] = pc.get("fatherName", "") d["newbornName"] = pc.get("newbornName", "") d["expectedDeliveryDate"] = pc.get("expectedDeliveryDate", "") d["newbornDeliveryTime"] = pc.get("newbornDeliveryTime", "") d["newbornSex"] = pc.get("newbornSex", "") d["deliveryMode"] = pc.get("deliveryMode", "") d["newbornWeightGram"] = pc.get("newbornWeightGram") else: d["motherName"] = "" d["motherPhone"] = "" d["motherIdCard"] = "" d["fatherName"] = "" d["newbornName"] = "" d["expectedDeliveryDate"] = "" d["newbornDeliveryTime"] = "" d["newbornSex"] = "" d["deliveryMode"] = "" d["newbornWeightGram"] = None ci = collection_map.get(r.id) if ci: d["regionName"] = region_name_map.get(ci.get("regionId", ""), "") d["hospitalName"] = hospital_name_map.get(ci.get("hospitalId", ""), "") d["salesUserName"] = user_name_map.get(ci.get("salesUserId", ""), "") d["sampleCollectTime"] = ci.get("sampleCollectTime", "") else: d["regionName"] = "" d["hospitalName"] = "" d["salesUserName"] = "" d["sampleCollectTime"] = "" donation_samples = sample_map.get(r.id, []) type_names: List[str] = [] codes: List[str] = [] sample_items: List[Dict[str, Any]] = [] for s in donation_samples: type_name = sample_type_name_map.get(s.sample_type_id or "", "") if type_name: type_names.append(type_name) code = (s.sample_code or "").strip() if code: codes.append(code) sample_items.append( { "sampleTypeName": type_name, "sampleCode": code, "sampleStatus": (s.sample_status or "").strip(), "labStorageCode": lab_code_map.get(code, "") if code else "", **sample_special_to_dict(s), **build_sample_item_reception_qc_fields( reception_summary_map.get(code) if code else None, qc_status_map.get(code) if code else None, ), } ) d["sampleItems"] = sample_items d["sampleTypeNames"] = ",".join(type_names) d["sampleCodes"] = ",".join(codes) d["labStorageCodes"] = ",".join((lab_code_map.get(c) or "") for c in codes) d["sampleCollectSummary"] = _format_sample_collect_summary(donation_samples) result.append(d) return { "list": result, "total": int(total), "page": page, "pageSize": page_size, "totalPages": total_pages(int(total), page_size), } # --------------------------------------------------------------------------- # 2. get_donation_detail # --------------------------------------------------------------------------- def get_donation_detail( db: Session, donation_id: str, tenant_no: str, ) -> Optional[Dict[str, Any]]: """查询单条捐赠详情,含全部子资源嵌套。""" main = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not main: return None out: Dict[str, Any] = {"donation": _donation_to_dict(main, db, tenant_no)} # 采集专员名称 user_ids: List[str] = [] if main.collector_user_id: user_ids.append(main.collector_user_id) # party party = get_party(db, donation_id, tenant_no) out["party"] = _party_to_dict(party) if party else None if party and party.collector_user_id: user_ids.append(party.collector_user_id) # collection coll = get_collection(db, donation_id, tenant_no) coll_dict = _collection_to_dict(coll) if coll else None if coll_dict and coll_dict.get("salesUserId"): user_ids.append(coll_dict["salesUserId"]) if coll_dict: region_id = str(coll_dict.get("regionId") or "") if region_id: r = db.query(BizCollectionRegion.region_name).filter( BizCollectionRegion.id == region_id ).first() coll_dict["regionName"] = r.region_name if r else "" hospital_id = str(coll_dict.get("hospitalId") or "") if hospital_id: h = db.query(BizCollectionHospital.hospital_name).filter( BizCollectionHospital.id == hospital_id ).first() coll_dict["hospitalName"] = h.hospital_name if h else "" out["collection"] = coll_dict # samples + transport s_rows = list_samples(db, donation_id, tenant_no) transport_rows = list_sample_transports(db, donation_id, tenant_no) site_name_map = _load_site_name_map( db, [t.dest_site_id for t in transport_rows if t.dest_site_id] ) transport_by_sample: Dict[str, Dict[str, Any]] = {} for t in transport_rows: td = _transport_to_dict(t) td["destSiteName"] = site_name_map.get(t.dest_site_id or "", "") transport_by_sample[t.sample_id] = td from app.crud import sample_reception as sample_reception_svc from app.crud import qc_work_order as qc_work_order_svc from app.crud import production_work_order as production_work_order_svc from app.crud import storage_release_review as storage_release_review_svc sample_ids = [s.id for s in s_rows] sample_code_by_id = {s.id: s.sample_code or "" for s in s_rows} reception_by_sample = sample_reception_svc.get_receptions_map_for_donation_samples( db, sample_ids, tenant_no ) qc_by_sample = qc_work_order_svc.get_qc_info_map_for_samples( db, sample_ids, tenant_no, ) process_qc_by_sample = qc_work_order_svc.get_process_qc_info_map_for_samples( db, sample_ids, tenant_no, sample_code_by_id=sample_code_by_id, ) production_by_sample = production_work_order_svc.get_production_info_map_for_samples( db, sample_ids, tenant_no ) storage_release_by_sample = storage_release_review_svc.get_storage_release_map_for_samples( db, sample_ids, tenant_no, sample_code_by_id=sample_code_by_id, ) samples_out: List[Dict[str, Any]] = [] unit_name_map = _load_unit_name_map(db, s_rows) for s in s_rows: item = _sample_to_dict(s) item["unitName"] = unit_name_map.get(s.unit_id or "", "") item["transport"] = transport_by_sample.get(s.id) item["sampleReception"] = reception_by_sample.get(s.id) item["productionReception"] = (reception_by_sample.get(s.id) or {}).get( "productionReception" ) item["qcInfo"] = qc_by_sample.get(s.id) item["processQcInfoList"] = process_qc_by_sample.get(s.id, []) item["productionInfo"] = production_by_sample.get(s.id) item["storageReleaseInfo"] = storage_release_by_sample.get(s.id) samples_out.append(item) out["samples"] = samples_out # user name map user_name_map = _user_nick_name_map(db, user_ids) out["donation"]["collectorUserName"] = user_name_map.get( main.collector_user_id or "", "" ) if coll_dict and coll_dict.get("salesUserId"): coll_dict["salesUserName"] = user_name_map.get( coll_dict["salesUserId"], "" ) return out # --------------------------------------------------------------------------- # 3. create_donation # --------------------------------------------------------------------------- def create_donation( db: Session, *, data: dict, tenant_no: str, user_id: Optional[str] = None, ) -> BizResearchDonation: """创建科研捐赠单(含 party / collection / samples + transports)。""" raw = _normalize_fields(data) _resolve_donation_payload(raw) consent = (raw.get("consent_form_urls") or "").strip() donation_no = (raw.get("donation_no") or "").strip() if not donation_no: donation_no = allocate_next_biz_no( db, tenant_no=tenant_no, kind="donation", user_id=user_id ) assert_biz_no_available( db, tenant_no=tenant_no, biz_no=donation_no, for_donation=True ) now = datetime.now() obj = BizResearchDonation( id=generate_uuid(), donation_no=donation_no, donation_status=(raw.get("donation_status") or "dsx").strip(), donation_date=(raw.get("donation_date") or "").strip() or None, source_type=(raw.get("source_type") or "direct").strip(), source_protocol_id=(raw.get("source_protocol_id") or "").strip() or None, consent_form_urls=consent or None, attachment_urls=(raw.get("attachment_urls") or "").strip() or None, infectious_disease_report_urls=( (raw.get("infectious_disease_report_urls") or "").strip() or None ), collector_user_id=(raw.get("collector_user_id") or "").strip() or None, tenant_no=tenant_no, order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(obj) db.flush() donation_id = obj.id # party party_raw = _normalize_fields(raw.get("party") or {}) _create_party(db, donation_id, tenant_no, user_id, party_raw, now) # collection coll_raw = _normalize_fields(raw.get("collection") or {}) _create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) # samples + transports samples_raw = raw.get("samples") or [] _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now) db.commit() db.refresh(obj) return obj # --------------------------------------------------------------------------- # 4. update_donation # --------------------------------------------------------------------------- def update_donation( db: Session, donation_id: str, *, data: dict, tenant_no: str, user_id: Optional[str] = None, ) -> BizResearchDonation: """更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。""" obj = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not obj: raise ValueError("捐赠单不存在或无权操作") raw = _normalize_fields(data) _resolve_donation_payload(raw) now = datetime.now() # 主表字段 _MAIN_STR_FIELDS = [ "donation_status", "donation_date", "source_type", "source_protocol_id", "consent_form_urls", "attachment_urls", "infectious_disease_report_urls", "collector_user_id", "effective_desc", "expire_desc", "void_desc", "to_storage_desc", "remark", "donation_no", ] _MAIN_DT_FIELDS = [ "effective_time", "expire_time", "void_time", "to_storage_time", ] for k in _MAIN_STR_FIELDS: if k in raw: if k == "donation_no": pn = (raw[k] or "").strip() if pn and pn != (obj.donation_no or ""): raise ValueError("捐赠协议号不可修改") else: val = raw[k] if val is None: continue setattr(obj, k, (val or "").strip() or None) for k in _MAIN_DT_FIELDS: if k in raw: val = raw[k] if isinstance(val, str): val = parse_datetime(val) setattr(obj, k, val) obj.update_by = user_id obj.update_time = now db.add(obj) db.flush() # party upsert: 软删旧 → 创建新 if "party" in raw: _soft_delete_party(db, donation_id, tenant_no, user_id) party_raw = _normalize_fields(raw["party"] or {}) _create_party(db, donation_id, tenant_no, user_id, party_raw, now) # collection upsert if "collection" in raw: _soft_delete_collection(db, donation_id, tenant_no, user_id) coll_raw = _normalize_fields(raw["collection"] or {}) _create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) # samples upsert if "samples" in raw: existing_samples = list_samples(db, donation_id, tenant_no) old_id_to_code = { s.id: (s.sample_code or "").strip() for s in existing_samples if (s.sample_code or "").strip() } _soft_delete_samples(db, donation_id, tenant_no, user_id) _soft_delete_transports(db, donation_id, tenant_no, user_id) new_samples = _create_samples(db, donation_id, tenant_no, user_id, raw["samples"] or [], now) new_code_to_id = { (s.sample_code or "").strip(): s.id for s in new_samples if (s.sample_code or "").strip() } _migrate_donation_sample_child_refs_on_replace( db, tenant_no=tenant_no, old_id_to_code=old_id_to_code, new_code_to_id=new_code_to_id, user_id=user_id, ) db.commit() db.refresh(obj) return obj # --------------------------------------------------------------------------- # 5. delete_donation / batch_delete_donations # --------------------------------------------------------------------------- def delete_donation( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str] = None, ) -> None: """软删除捐赠单 + 所有子资源。""" obj = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not obj: raise ValueError("捐赠单不存在或无权操作") _soft_delete_all(db, donation_id, tenant_no, user_id) db.commit() def batch_delete_donations( db: Session, ids: List[str], tenant_no: str, user_id: Optional[str] = None, ) -> int: """批量软删除捐赠单。""" count = 0 for did in (ids or []): did = str(did).strip() if not did: continue obj = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == did, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not obj: continue _soft_delete_all(db, did, tenant_no, user_id) count += 1 db.commit() return count # --------------------------------------------------------------------------- # 6. list_audits # --------------------------------------------------------------------------- def list_audits( db: Session, *, tenant_no: str, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, audit_status: Optional[str] = None, research_donation_id: Optional[str] = None, user_info=None, ) -> Dict[str, Any]: q_base = ( db.query(BizResearchDonationAudit, BizResearchDonation.donation_no) .join( BizResearchDonation, and_( BizResearchDonationAudit.research_donation_id == BizResearchDonation.id, BizResearchDonation.del_flag == 0, ), ) .filter(BizResearchDonationAudit.del_flag == 0) ) if tenant_no: q_base = q_base.filter(BizResearchDonationAudit.tenant_no == tenant_no) if audit_status: q_base = q_base.filter(BizResearchDonationAudit.audit_status == audit_status.strip()) did = (research_donation_id or "").strip() if did: q_base = q_base.filter(BizResearchDonationAudit.research_donation_id == did) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q_base = q_base.filter( or_( BizResearchDonation.donation_no.like(like), BizResearchDonationAudit.audit_remark.like(like), ) ) if user_info: from app.core.data_scope import apply_data_scope q_base = apply_data_scope(q_base, BizResearchDonationAudit, user_info, db, tenant_no) total = ( q_base.order_by(None) .with_entities(func.count(BizResearchDonationAudit.id)) .scalar() ) or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizResearchDonationAudit.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) auditor_ids = list({a.auditor_user_id for a, _ in rows if a.auditor_user_id}) auditor_name_map = _user_nick_name_map(db, auditor_ids) result = [ _audit_to_dict( a, donation_no=pn or "", auditor_user_name=auditor_name_map.get(a.auditor_user_id or "", ""), ) for a, pn in rows ] return { "list": result, "total": int(total), "page": page, "pageSize": page_size, "totalPages": total_pages(int(total), page_size), } # --------------------------------------------------------------------------- # 7. submit_audit # --------------------------------------------------------------------------- def submit_audit( db: Session, donation_id: str, *, audit_status: str, audit_remark: Optional[str] = None, user_id: Optional[str] = None, tenant_no: str = "", ) -> BizResearchDonationAudit: """提交审核。shtg → 捐赠状态变为 sxz;shth → 状态不变。""" # 校验捐赠单存在 main = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not main: raise ValueError("捐赠单不存在") st = (audit_status or "").strip() if st not in ("shtg", "shth"): raise ValueError("审核状态无效") now = datetime.now() obj = BizResearchDonationAudit( id=generate_uuid(), research_donation_id=donation_id, auditor_user_id=user_id, audit_remark=(audit_remark or "").strip() or None, audit_status=st, tenant_no=tenant_no, order_num=0, remark=None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(obj) # 审核通过 → 变更状态为 sxz if st == "shtg": main.donation_status = "sxz" main.effective_time = now main.update_by = user_id main.update_time = now db.add(main) db.commit() db.refresh(obj) return obj # --------------------------------------------------------------------------- # 8. change_status # --------------------------------------------------------------------------- # 合法状态转换: {当前状态: {可转状态列表}} _VALID_TRANSITIONS: Dict[str, List[str]] = { "dsx": ["sxz", "yzf", "sdfq"], "sxz": ["ygq", "yzf", "zckc", "sdfq"], } def change_status( db: Session, donation_id: str, *, new_status: str, desc: Optional[str] = None, time_val: Optional[Any] = None, void_type: Optional[str] = None, user_id: Optional[str] = None, tenant_no: str = "", ) -> BizResearchDonation: """变更捐赠单状态(含合法性校验)。""" obj = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not obj: raise ValueError("捐赠单不存在") current = obj.donation_status or "" ns = (new_status or "").strip() if current != ns: allowed = _VALID_TRANSITIONS.get(current, []) if ns not in allowed: raise ValueError(f"状态 {current} 不可转为 {ns}") now = datetime.now() if isinstance(time_val, str): time_val = parse_datetime(time_val) effective_time = time_val or now if current != ns: obj.donation_status = ns obj.update_by = user_id obj.update_time = now # 更新对应的时间/描述字段 if ns == "sxz": obj.effective_time = effective_time obj.effective_desc = (desc or "").strip() or None elif ns == "yzf": obj.void_time = effective_time obj.void_desc = (desc or "").strip() or None from app.crud.dict_data import normalize_void_type obj.void_type = normalize_void_type(db, value=void_type or "", tenant_no=tenant_no) elif ns == "ygq": obj.expire_time = effective_time obj.expire_desc = (desc or "").strip() or None elif ns == "zckc": obj.to_storage_time = effective_time obj.to_storage_desc = (desc or "").strip() or None db.add(obj) db.commit() db.refresh(obj) return obj # --------------------------------------------------------------------------- # 9. convert_from_storage # --------------------------------------------------------------------------- def convert_from_storage( db: Session, protocol_id: str, *, tenant_no: str, user_id: Optional[str] = None, ) -> BizResearchDonation: """从细胞存储协议单转换创建科研捐赠单。""" # 读取源协议 proto = ( db.query(BizCollectionProtocol) .filter( and_( BizCollectionProtocol.id == protocol_id, BizCollectionProtocol.tenant_no == tenant_no, BizCollectionProtocol.del_flag == 0, ) ) .first() ) if not proto: raise ValueError("源协议单不存在") if proto.protocol_status != "sxz": raise ValueError("仅生效中的协议可转科研") if has_active_convert_link(db, tenant_no, protocol_id=protocol_id): raise ValueError("该协议已存在转移关联,无法重复转科研") pn_src = (proto.protocol_no or "").strip() if not pn_src: raise ValueError("源协议单协议号为空,无法转科研") # 读取源子资源 src_party = ( db.query(BizProtocolPartyCustomer) .filter( and_( BizProtocolPartyCustomer.collection_protocol_id == protocol_id, BizProtocolPartyCustomer.tenant_no == tenant_no, BizProtocolPartyCustomer.del_flag == 0, ) ) .first() ) src_coll = ( db.query(BizProtocolCollectionInfo) .filter( and_( BizProtocolCollectionInfo.collection_protocol_id == protocol_id, BizProtocolCollectionInfo.tenant_no == tenant_no, BizProtocolCollectionInfo.del_flag == 0, ) ) .first() ) src_samples = ( db.query(BizProtocolSample) .filter( and_( BizProtocolSample.collection_protocol_id == protocol_id, BizProtocolSample.tenant_no == tenant_no, BizProtocolSample.del_flag == 0, ) ) .all() ) src_transports = ( db.query(BizProtocolSampleTransport) .filter( and_( BizProtocolSampleTransport.collection_protocol_id == protocol_id, BizProtocolSampleTransport.tenant_no == tenant_no, BizProtocolSampleTransport.del_flag == 0, ) ) .all() ) transport_by_sample = {t.sample_id: t for t in src_transports} now = datetime.now() donation_no = pn_src # 创建主记录 donation = BizResearchDonation( id=generate_uuid(), donation_no=donation_no, donation_status="dsx", donation_date=now.strftime("%Y%m%d"), source_type="from_storage", source_protocol_id=protocol_id, attachment_urls=(proto.protocol_attachments or "").strip() or None, collector_user_id=proto.collector_user_id, tenant_no=tenant_no, order_num=0, remark=f"由细胞存储协议单 {proto.protocol_no} 转入", create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(donation) db.flush() did = donation.id # 创建 party if src_party: party = BizResearchDonationParty( id=generate_uuid(), research_donation_id=did, customer_id=src_party.customer_id, mother_name=src_party.mother_name, mother_phone=src_party.mother_phone, mother_id_card=src_party.mother_id_card, mother_birth_ym=src_party.mother_birth_ym, mother_ethnic=src_party.mother_ethnic, mother_email=src_party.mother_email, mother_address=src_party.mother_address, father_name=src_party.father_name, father_phone=src_party.father_phone, father_id_card=src_party.father_id_card, newborn_name=src_party.newborn_name, newborn_id_card=src_party.newborn_id_card, expected_delivery_date=src_party.expected_delivery_date, newborn_delivery_time=src_party.newborn_delivery_time, newborn_sex=src_party.newborn_sex, delivery_mode=src_party.delivery_mode, birth_order=src_party.birth_order, newborn_weight_gram=src_party.newborn_weight_gram, apgar_1min=src_party.apgar_1min, apgar_5min=src_party.apgar_5min, collector_user_id=src_party.collector_user_id, tenant_no=tenant_no, order_num=0, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(party) # 创建 collection if src_coll: coll = BizResearchDonationCollection( id=generate_uuid(), research_donation_id=did, region_id=src_coll.region_id, hospital_id=src_coll.hospital_id, medical_staff=src_coll.medical_staff, sales_user_id=src_coll.sales_user_id, sample_collect_time=src_coll.sample_collect_time, maternal_blood_collect_time=src_coll.maternal_blood_collect_time, tenant_no=tenant_no, order_num=0, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(coll) # 创建 samples + transports for idx, ss in enumerate(src_samples): new_sample = BizResearchDonationSample( id=generate_uuid(), research_donation_id=did, sample_type_id=ss.sample_type_id, sample_code_id=ss.sample_code_id, sample_code=ss.sample_code, collect_amount=ss.collect_amount, unit_id=ss.unit_id, storage_years=ss.storage_years, sample_status=ss.sample_status, storage_in_date=ss.storage_in_date, detect_start_date=ss.detect_start_date, void_date=ss.void_date, storage_expire_date=ss.storage_expire_date, is_special=getattr(ss, "is_special", 0), special_remark=getattr(ss, "special_remark", None), tenant_no=tenant_no, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(new_sample) db.flush() # 对应运输记录 src_t = transport_by_sample.get(ss.id) if src_t: new_t = BizResearchDonationSampleTransport( id=generate_uuid(), research_donation_id=did, sample_id=new_sample.id, sample_code=ss.sample_code or None, transport_type=src_t.transport_type, express_company_id=src_t.express_company_id, waybill_no=src_t.waybill_no, dest_site_id=src_t.dest_site_id, express_fee=src_t.express_fee, eta_time=src_t.eta_time, carrier_name=src_t.carrier_name, carrier_phone=src_t.carrier_phone, tenant_no=tenant_no, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(new_t) # 更新源协议状态为 zky(转科研) proto.protocol_status = "zky" proto.to_research_time = now proto.to_research_desc = f"已转入科研捐赠 {donation_no}" proto.update_by = user_id proto.update_time = now db.add(proto) create_convert_link( db, convert_type="storage_to_research", protocol_id=protocol_id, research_donation_id=did, biz_no=donation_no, tenant_no=tenant_no, user_id=user_id, now=now, ) db.commit() db.refresh(donation) return donation # --------------------------------------------------------------------------- # 10. convert_to_storage # --------------------------------------------------------------------------- def convert_to_storage( db: Session, donation_id: str, *, tenant_no: str, user_id: Optional[str] = None, ) -> BizResearchDonation: """从科研捐赠单转换创建细胞存储协议单,返回更新后的捐赠单。""" # 读取源捐赠 donation = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if not donation: raise ValueError("捐赠单不存在") if donation.donation_status != "sxz": raise ValueError("仅生效中的捐赠单可转细胞存储") partner = get_convert_partner(db, tenant_no, donation_id=donation_id) if partner: linked_proto = ( db.query(BizCollectionProtocol) .filter( and_( BizCollectionProtocol.id == partner["protocol_id"], BizCollectionProtocol.tenant_no == tenant_no, BizCollectionProtocol.del_flag == 0, ) ) .first() ) if linked_proto and linked_proto.protocol_status != "zky": raise ValueError("该捐赠单已转细胞存储,无法重复操作") dn_src = (donation.donation_no or "").strip() if not dn_src: raise ValueError("源捐赠单协议号为空,无法转细胞存储") src_party = get_party(db, donation_id, tenant_no) src_coll = get_collection(db, donation_id, tenant_no) src_samples = list_samples(db, donation_id, tenant_no) src_transports = list_sample_transports(db, donation_id, tenant_no) transport_by_sample = {t.sample_id: t for t in src_transports} now = datetime.now() protocol_no = dn_src protocol_no_id = None if (donation.source_protocol_id or "").strip(): src_proto = ( db.query(BizCollectionProtocol) .filter( and_( BizCollectionProtocol.id == donation.source_protocol_id.strip(), BizCollectionProtocol.tenant_no == tenant_no, BizCollectionProtocol.del_flag == 0, ) ) .first() ) if src_proto and src_proto.protocol_no_id: protocol_no_id = src_proto.protocol_no_id protocol_attachment_urls = _merge_comma_separated_urls( donation.consent_form_urls, donation.infectious_disease_report_urls, donation.attachment_urls, ) # 创建协议单 proto = BizCollectionProtocol( id=generate_uuid(), protocol_no_id=protocol_no_id, protocol_no=protocol_no, protocol_status="dsx", register_date=now.strftime("%Y%m%d"), protocol_attachments=protocol_attachment_urls, collector_user_id=donation.collector_user_id, tenant_no=tenant_no, order_num=0, remark=f"由科研捐赠单 {donation.donation_no} 转入", create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(proto) db.flush() pid = proto.id # 创建 party if src_party: party = BizProtocolPartyCustomer( id=generate_uuid(), collection_protocol_id=pid, customer_id=src_party.customer_id, mother_name=src_party.mother_name, mother_phone=src_party.mother_phone, mother_id_card=src_party.mother_id_card, mother_birth_ym=src_party.mother_birth_ym, mother_ethnic=src_party.mother_ethnic, mother_email=src_party.mother_email, mother_address=src_party.mother_address, father_name=src_party.father_name, father_phone=src_party.father_phone, father_id_card=src_party.father_id_card, newborn_name=src_party.newborn_name, newborn_id_card=src_party.newborn_id_card, expected_delivery_date=src_party.expected_delivery_date, newborn_delivery_time=src_party.newborn_delivery_time, newborn_sex=src_party.newborn_sex, delivery_mode=src_party.delivery_mode, birth_order=src_party.birth_order, newborn_weight_gram=src_party.newborn_weight_gram, apgar_1min=src_party.apgar_1min, apgar_5min=src_party.apgar_5min, collector_user_id=src_party.collector_user_id, tenant_no=tenant_no, order_num=0, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(party) # 创建 collection if src_coll: coll = BizProtocolCollectionInfo( id=generate_uuid(), collection_protocol_id=pid, region_id=src_coll.region_id, hospital_id=src_coll.hospital_id, medical_staff=src_coll.medical_staff, sales_user_id=src_coll.sales_user_id, sample_collect_time=src_coll.sample_collect_time, maternal_blood_collect_time=src_coll.maternal_blood_collect_time, tenant_no=tenant_no, order_num=0, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(coll) # 创建 samples + transports(无 price 字段 → null) for idx, ss in enumerate(src_samples): new_sample = BizProtocolSample( id=generate_uuid(), collection_protocol_id=pid, sample_type_id=ss.sample_type_id, sample_code_id=ss.sample_code_id, sample_code=ss.sample_code, collect_amount=ss.collect_amount, unit_id=ss.unit_id, price=None, storage_years=ss.storage_years, sample_status=ss.sample_status, storage_in_date=ss.storage_in_date, detect_start_date=ss.detect_start_date, void_date=ss.void_date, storage_expire_date=ss.storage_expire_date, is_special=ss.is_special, special_remark=ss.special_remark, tenant_no=tenant_no, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(new_sample) db.flush() src_t = transport_by_sample.get(ss.id) if src_t: new_t = BizProtocolSampleTransport( id=generate_uuid(), collection_protocol_id=pid, sample_id=new_sample.id, sample_code=ss.sample_code or None, transport_type=src_t.transport_type, express_company_id=src_t.express_company_id, waybill_no=src_t.waybill_no, dest_site_id=src_t.dest_site_id, express_fee=src_t.express_fee, eta_time=src_t.eta_time, carrier_name=src_t.carrier_name, carrier_phone=src_t.carrier_phone, tenant_no=tenant_no, order_num=idx, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(new_t) # 更新捐赠单状态为 zckc donation.donation_status = "zckc" donation.to_storage_time = now donation.to_storage_desc = f"已转细胞存储 {protocol_no}" donation.update_by = user_id donation.update_time = now db.add(donation) upsert_convert_link_for_to_storage( db, protocol_id=pid, research_donation_id=donation_id, biz_no=protocol_no, tenant_no=tenant_no, user_id=user_id, now=now, ) db.commit() db.refresh(donation) return donation # --------------------------------------------------------------------------- # Internal: sub-resource create helpers # --------------------------------------------------------------------------- _PARTY_STR_FIELDS = [ "customer_id", "mother_name", "mother_phone", "mother_id_card", "mother_birth_ym", "mother_ethnic", "mother_email", "mother_address", "father_name", "father_phone", "father_id_card", "newborn_name", "newborn_id_card", "newborn_sex", "delivery_mode", "collector_user_id", ] _PARTY_INT_FIELDS = ["birth_order", "newborn_weight_gram"] _PARTY_DEC_FIELDS = ["apgar_1min", "apgar_5min"] _PARTY_DATE_FIELDS = ["expected_delivery_date"] _PARTY_DT_FIELDS = ["newborn_delivery_time"] def _create_party( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], raw: dict, now: datetime, ) -> BizResearchDonationParty: obj = BizResearchDonationParty( id=generate_uuid(), research_donation_id=donation_id, tenant_no=tenant_no, order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) for k in _PARTY_STR_FIELDS: if k in raw: setattr(obj, k, (raw[k] or "").strip() or None) for k in _PARTY_INT_FIELDS: if k in raw: setattr(obj, k, raw[k]) for k in _PARTY_DEC_FIELDS: if k in raw: setattr(obj, k, _optional_decimal(raw[k])) for k in _PARTY_DATE_FIELDS: if k in raw: setattr(obj, k, optional_date(raw[k])) for k in _PARTY_DT_FIELDS: if k in raw: val = raw[k] if isinstance(val, str): val = parse_datetime(val) setattr(obj, k, val) db.add(obj) db.flush() return obj _COLL_STR_FIELDS = ["region_id", "hospital_id", "medical_staff", "sales_user_id"] _COLL_DT_FIELDS = ["sample_collect_time", "maternal_blood_collect_time"] def _create_collection( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], raw: dict, now: datetime, ) -> BizResearchDonationCollection: obj = BizResearchDonationCollection( id=generate_uuid(), research_donation_id=donation_id, tenant_no=tenant_no, order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) for k in _COLL_STR_FIELDS: if k in raw: setattr(obj, k, (raw[k] or "").strip() or None) for k in _COLL_DT_FIELDS: if k in raw: val = raw[k] if isinstance(val, str): val = parse_datetime(val) setattr(obj, k, val) db.add(obj) db.flush() return obj _SAMPLE_STR_FIELDS = [ "sample_type_id", "sample_code_id", "sample_code", "unit_id", "sample_status", "storage_in_date", "detect_start_date", "void_date", "storage_expire_date", ] _SAMPLE_DEC_FIELDS = ["collect_amount"] _SAMPLE_INT_FIELDS = ["storage_years"] def _migrate_donation_sample_child_refs_on_replace( db: Session, *, tenant_no: str, old_id_to_code: Dict[str, str], new_code_to_id: Dict[str, str], user_id: Optional[str], ) -> None: """捐赠样本行重建后,将接收/质检工单的捐赠样本 ID 迁移到新样本 ID。""" if not old_id_to_code or not new_code_to_id: return old_ids = list(old_id_to_code.keys()) now = datetime.now() rec_rows = ( db.query(BizSampleReception) .filter( BizSampleReception.donation_sample_id.in_(old_ids), BizSampleReception.tenant_no == tenant_no, BizSampleReception.del_flag == 0, BizSampleReception.reception_source == "research_donation", ) .all() ) for row in rec_rows: old_sid = (row.donation_sample_id or "").strip() code = old_id_to_code.get(old_sid) or (row.sample_code or "").strip() new_sid = new_code_to_id.get(code) if code else None if not new_sid: continue row.donation_sample_id = new_sid row.update_by = user_id row.update_time = now db.add(row) wo_rows = ( db.query(BizQcWorkOrder) .filter( BizQcWorkOrder.protocol_sample_id.in_(old_ids), BizQcWorkOrder.tenant_no == tenant_no, BizQcWorkOrder.del_flag == 0, BizQcWorkOrder.reception_source == "research_donation", ) .all() ) for row in wo_rows: old_sid = (row.protocol_sample_id or "").strip() code = old_id_to_code.get(old_sid) or (row.sample_code or "").strip() new_sid = new_code_to_id.get(code) if code else None if not new_sid: continue row.protocol_sample_id = new_sid row.update_by = user_id row.update_time = now db.add(row) def _create_samples( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], samples: list, now: datetime, ) -> List[BizResearchDonationSample]: validate_sample_codes_not_used( db, tenant_no=tenant_no, samples=samples, exclude_donation_id=donation_id, ) result = [] assigned_codes: List[str] = [] for idx, raw_item in enumerate(samples or []): raw = _normalize_fields(raw_item) validate_sample_special_raw(raw, index=idx) code = (raw.get("sample_code") or "").strip() if not code and (raw.get("sample_type_id") or "").strip(): code = next_donation_sample_code( db, tenant_no=tenant_no, extra_codes=assigned_codes, ) raw["sample_code"] = code if code: assigned_codes.append(code) obj = BizResearchDonationSample( id=generate_uuid(), research_donation_id=donation_id, tenant_no=tenant_no, order_num=idx, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) for k in _SAMPLE_STR_FIELDS: if k == "sample_status": continue if k in raw: setattr(obj, k, (raw[k] or "").strip() or None) if "sample_status" in raw: status_val = raw.get("sample_status") obj.sample_status = ( str(status_val).strip() if status_val is not None and str(status_val).strip() else None ) for k in _SAMPLE_DEC_FIELDS: if k in raw and raw[k] is not None: setattr(obj, k, Decimal(str(raw[k]))) for k in _SAMPLE_INT_FIELDS: if k in raw: setattr(obj, k, raw[k]) apply_sample_special_fields(obj, raw) db.add(obj) db.flush() result.append(obj) # transport transport_raw = raw.get("transport") if isinstance(transport_raw, dict): _create_transport(db, donation_id, obj.id, tenant_no, user_id, transport_raw, idx, now, sample_code=obj.sample_code) return result def _create_transport( db: Session, donation_id: str, sample_id: str, tenant_no: str, user_id: Optional[str], raw: dict, order_num: int, now: datetime, sample_code: Optional[str] = None, ) -> Optional[BizResearchDonationSampleTransport]: tnorm = _normalize_fields(raw) transport_type = (tnorm.get("transport_type") or "").strip() if not transport_type: return None eta_time = tnorm.get("eta_time") if isinstance(eta_time, str): eta_time = parse_datetime(eta_time) express_fee = tnorm.get("express_fee") if express_fee is not None and express_fee != "": express_fee = Decimal(str(express_fee)) else: express_fee = None obj = BizResearchDonationSampleTransport( id=generate_uuid(), research_donation_id=donation_id, sample_id=sample_id, sample_code=sample_code or None, transport_type=transport_type, express_company_id=(tnorm.get("express_company_id") or "").strip() or None, waybill_no=(tnorm.get("waybill_no") or "").strip() or None, dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None, express_fee=express_fee, eta_time=eta_time, carrier_name=(tnorm.get("carrier_name") or "").strip() or None, carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None, tenant_no=tenant_no, order_num=order_num, remark=(tnorm.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) db.add(obj) db.flush() return obj # --------------------------------------------------------------------------- # Internal: soft delete helpers # --------------------------------------------------------------------------- def _soft_delete_party( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], ) -> None: rows = ( db.query(BizResearchDonationParty) .filter( and_( BizResearchDonationParty.research_donation_id == donation_id, BizResearchDonationParty.tenant_no == tenant_no, BizResearchDonationParty.del_flag == 0, ) ) .all() ) for r in rows: r.del_flag = 1 r.update_by = user_id r.update_time = datetime.now() db.add(r) if rows: db.flush() def _soft_delete_collection( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], ) -> None: rows = ( db.query(BizResearchDonationCollection) .filter( and_( BizResearchDonationCollection.research_donation_id == donation_id, BizResearchDonationCollection.tenant_no == tenant_no, BizResearchDonationCollection.del_flag == 0, ) ) .all() ) for r in rows: r.del_flag = 1 r.update_by = user_id r.update_time = datetime.now() db.add(r) if rows: db.flush() def _soft_delete_samples( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], ) -> None: rows = ( db.query(BizResearchDonationSample) .filter( and_( BizResearchDonationSample.research_donation_id == donation_id, BizResearchDonationSample.tenant_no == tenant_no, BizResearchDonationSample.del_flag == 0, ) ) .all() ) for r in rows: r.del_flag = 1 r.update_by = user_id r.update_time = datetime.now() db.add(r) if rows: db.flush() def _soft_delete_transports( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], ) -> None: rows = ( db.query(BizResearchDonationSampleTransport) .filter( and_( BizResearchDonationSampleTransport.research_donation_id == donation_id, BizResearchDonationSampleTransport.tenant_no == tenant_no, BizResearchDonationSampleTransport.del_flag == 0, ) ) .all() ) for r in rows: r.del_flag = 1 r.update_by = user_id r.update_time = datetime.now() db.add(r) if rows: db.flush() def _soft_delete_all( db: Session, donation_id: str, tenant_no: str, user_id: Optional[str], ) -> None: """软删除主记录 + 所有子资源。""" now = datetime.now() # 主记录 main = ( db.query(BizResearchDonation) .filter( and_( BizResearchDonation.id == donation_id, BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, ) ) .first() ) if main: main.del_flag = 1 main.update_by = user_id main.update_time = now db.add(main) _soft_delete_party(db, donation_id, tenant_no, user_id) _soft_delete_collection(db, donation_id, tenant_no, user_id) _soft_delete_samples(db, donation_id, tenant_no, user_id) _soft_delete_transports(db, donation_id, tenant_no, user_id)