Files
zkxw/backend/app/crud/research_donation.py
T
zlwuandCursor 468a7c5e12 feat(backend): expose sample freight APIs and wire protocol saves
Register admin sample-freight routes, enrich protocol sample details with freight, and stop writing legacy transport rows on save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 07:48:41 +08:00

2340 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""科研捐赠管理 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 "",
"fatherCustomerId": p.father_customer_id or "",
"newbornCustomerId": p.newborn_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)
from app.crud import sample_freight as sample_freight_svc
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
sample_ids=sample_ids,
)
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["freight"] = freight_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
from app.services.party_customer_sync import sync_party_customers
party_raw = _normalize_fields(raw.get("party") or {})
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id 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(运费批次在 commit 后挂钩;停写旧运输依赖)
samples_raw = raw.get("samples") or []
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
db.commit()
db.refresh(obj)
from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
protocol_id=donation_id,
saved_samples=new_samples,
input_samples=samples_raw,
)
db.commit()
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:
from app.services.party_customer_sync import sync_party_customers
_soft_delete_party(db, donation_id, tenant_no, user_id)
party_raw = _normalize_fields(raw["party"] or {})
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id 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)
samples_raw = raw["samples"] or []
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, 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,
)
else:
samples_raw = None
new_samples = None
db.commit()
db.refresh(obj)
if new_samples is not None and samples_raw is not None:
from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
protocol_id=donation_id,
saved_samples=new_samples,
input_samples=samples_raw,
)
db.commit()
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,
father_customer_id=getattr(src_party, "father_customer_id", None),
newborn_customer_id=getattr(src_party, "newborn_customer_id", None),
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,
father_customer_id=getattr(src_party, "father_customer_id", None),
newborn_customer_id=getattr(src_party, "newborn_customer_id", None),
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", "father_customer_id", "newborn_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)
# 本阶段不再写入旧运输明细;运费由 apply_freight_on_protocol_save 挂钩
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)