Files
zkxw/backend/app/crud/research_donation.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

2821 lines
93 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.protocol_status_display import assert_protocol_status_transition
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
from app.crud.child_row_sync import sync_child_rows
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 (
append_protocol_status_change,
attachments_to_urls,
urls_to_attachment_list,
validate_persisted_sample_codes_immutable,
validate_sample_codes_not_used,
_format_sample_collect_summary,
_load_express_name_map,
_load_sample_type_name_map,
_load_site_name_map,
_normalize_yyyymmdd_date,
_owned_child_row,
_retarget_protocol_sample_id,
)
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,
BizProductionWorkOrder,
BizQcSmsNotification,
BizQcWorkOrder,
BizSampleReception,
SysUser,
)
from app.models.research_donation import (
BizDonationStatusChange,
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_STATUS_CHANGE_TYPE = {
"sxz": "activate",
"ygq": "expire",
"yzf": "void",
"zckc": "to_storage",
}
def _donation_status_change_to_dict(
row: BizDonationStatusChange,
*,
operator_user_name: str = "",
) -> Dict[str, Any]:
return {
"id": row.id,
"researchDonationId": row.research_donation_id,
"changeType": row.change_type or "",
"fromStatus": row.from_status or "",
"toStatus": row.to_status or "",
"changeTime": _fmt_dt(row.change_time),
"changeDesc": row.change_desc or "",
"voidType": row.void_type or "",
"operatorUserId": row.operator_user_id or "",
"operatorUserName": operator_user_name or "",
"tenantNo": row.tenant_no,
"createTime": _fmt_dt(row.create_time),
"updateTime": _fmt_dt(row.update_time),
}
def append_donation_status_change(
db: Session,
*,
research_donation_id: str,
change_type: str,
from_status: Optional[str],
to_status: str,
change_time: datetime,
change_desc: Optional[str] = None,
void_type: Optional[str] = None,
tenant_no: str,
user_id: Optional[str] = None,
) -> BizDonationStatusChange:
"""追加一条捐赠状态变更历史(不 commit)。"""
now = datetime.now()
obj = BizDonationStatusChange(
id=generate_uuid(),
research_donation_id=research_donation_id,
change_type=(change_type or "").strip(),
from_status=(from_status or "").strip() or None,
to_status=(to_status or "").strip(),
change_time=change_time,
change_desc=(change_desc or "").strip() or None,
void_type=(void_type or "").strip() or None,
operator_user_id=user_id,
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)
return obj
def list_donation_status_changes(
db: Session,
*,
research_donation_id: str,
tenant_no: str,
) -> List[Dict[str, Any]]:
did = (research_donation_id or "").strip()
if not did:
return []
rows = (
db.query(BizDonationStatusChange)
.filter(
BizDonationStatusChange.research_donation_id == did,
BizDonationStatusChange.tenant_no == tenant_no,
BizDonationStatusChange.del_flag == 0,
)
.order_by(
BizDonationStatusChange.change_time.asc(),
BizDonationStatusChange.create_time.asc(),
)
.all()
)
op_ids = list({r.operator_user_id for r in rows if r.operator_user_id})
name_map: Dict[str, str] = {}
if op_ids:
for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(op_ids)).all():
name_map[u.id] = u.nick_name or ""
return [
_donation_status_change_to_dict(
r, operator_user_name=name_map.get(r.operator_user_id or "", "")
)
for r in rows
]
_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 allocate_monthly_verify_no(
db: Session,
*,
tenant_no: str,
create_time: datetime,
) -> str:
"""按租户 + create_time 年月分配 YYYYMM###;含软删占用,不回收。"""
ym = create_time.strftime("%Y%m")
prefix_len = len(ym)
row = (
db.query(BizResearchDonation.monthly_verify_no)
.filter(
BizResearchDonation.tenant_no == tenant_no,
BizResearchDonation.monthly_verify_no.isnot(None),
BizResearchDonation.monthly_verify_no.like(f"{ym}%"),
)
.order_by(BizResearchDonation.monthly_verify_no.desc())
.first()
)
max_no = (row[0] if row else None) or ""
seq = 0
if max_no.startswith(ym) and len(max_no) >= prefix_len + 3:
try:
seq = int(max_no[prefix_len : prefix_len + 3])
except ValueError:
seq = 0
nxt = seq + 1
if nxt > 999:
raise ValueError(f"当月核量号已用尽({ym})")
return f"{ym}{nxt:03d}"
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 "",
"monthlyVerifyNo": d.monthly_verify_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 "",
"regionManagerUserId": c.region_manager_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} - {""}
)
region_manager_user_ids = list(
{
collection_map.get(did, {}).get("regionManagerUserId", "")
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 + region_manager_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"
)
transport_by_sample_id: Dict[str, BizResearchDonationSampleTransport] = {}
if donation_ids:
for t in (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id.in_(donation_ids),
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.all()
):
transport_by_sample_id[t.sample_id] = t
site_name_map = _load_site_name_map(
db, [t.dest_site_id for t in transport_by_sample_id.values() if t.dest_site_id]
)
express_name_map = _load_express_name_map(
db,
[
t.express_company_id
for t in transport_by_sample_id.values()
if t.express_company_id
],
)
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")
d["birthOrder"] = pc.get("birthOrder")
else:
d["motherName"] = ""
d["motherPhone"] = ""
d["motherIdCard"] = ""
d["fatherName"] = ""
d["newbornName"] = ""
d["expectedDeliveryDate"] = ""
d["newbornDeliveryTime"] = ""
d["newbornSex"] = ""
d["deliveryMode"] = ""
d["newbornWeightGram"] = None
d["birthOrder"] = 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["regionManagerUserName"] = user_name_map.get(
ci.get("regionManagerUserId", ""), ""
)
d["sampleCollectTime"] = ci.get("sampleCollectTime", "")
d["maternalBloodCollectTime"] = ci.get("maternalBloodCollectTime", "")
else:
d["regionName"] = ""
d["hospitalName"] = ""
d["salesUserName"] = ""
d["regionManagerUserName"] = ""
d["sampleCollectTime"] = ""
d["maternalBloodCollectTime"] = ""
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)
transport_obj = transport_by_sample_id.get(s.id)
transport_dict: Optional[Dict[str, Any]] = None
if transport_obj and (
(transport_obj.transport_type or "").strip() or transport_obj.eta_time
):
dest_site_id = transport_obj.dest_site_id or ""
express_company_id = transport_obj.express_company_id or ""
transport_dict = {
"transportType": transport_obj.transport_type or "",
"destSiteId": dest_site_id,
"destSiteName": site_name_map.get(dest_site_id, ""),
"expressCompanyId": express_company_id,
"expressCompanyName": express_name_map.get(express_company_id, ""),
"waybillNo": transport_obj.waybill_no or "",
"carrierName": transport_obj.carrier_name or "",
"carrierPhone": transport_obj.carrier_phone or "",
"etaTime": _fmt_dt(transport_obj.eta_time),
}
sample_items.append(
{
"sampleTypeName": type_name,
"sampleCode": code,
"sampleStatus": (s.sample_status or "").strip(),
"labStorageCode": lab_code_map.get(code, "") if code else "",
"transport": transport_dict,
**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 and coll_dict.get("regionManagerUserId"):
user_ids.append(coll_dict["regionManagerUserId"])
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"], ""
)
if coll_dict:
coll_dict["regionManagerUserName"] = user_name_map.get(
coll_dict.get("regionManagerUserId") or "", ""
)
return out
# ---------------------------------------------------------------------------
# 3. create_donation
# ---------------------------------------------------------------------------
def create_donation(
db: Session,
*,
data: dict,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True,
sync_customers: bool = True,
) -> 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()
monthly_verify_no = allocate_monthly_verify_no(
db, tenant_no=tenant_no, create_time=now
)
obj = BizResearchDonation(
id=generate_uuid(),
donation_no=donation_no,
monthly_verify_no=monthly_verify_no,
donation_status=(raw.get("donation_status") or "dsx").strip(),
donation_date=_normalize_yyyymmdd_date(raw.get("donation_date")),
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_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 {})
if sync_customers:
party_raw = sync_party_customers(
db,
party_raw,
tenant_no=tenant_no,
user_id=user_id or "",
commit=commit,
create_dept_id=create_dept_id,
)
_create_party(
db, donation_id, tenant_no, user_id, party_raw, now,
create_dept_id=create_dept_id,
)
# collection
coll_raw = _normalize_fields(raw.get("collection") or {})
_create_collection(
db, donation_id, tenant_no, user_id, coll_raw, now,
create_dept_id=create_dept_id,
)
# samples(运费批次在 commit 后挂钩;停写旧运输依赖)
samples_raw = raw.get("samples") or []
new_samples = _create_samples(
db, donation_id, tenant_no, user_id, samples_raw, now,
create_dept_id=create_dept_id,
)
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,
)
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj
# ---------------------------------------------------------------------------
# 4. update_donation
# ---------------------------------------------------------------------------
def update_donation(
db: Session,
donation_id: str,
*,
data: dict,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True,
sync_customers: bool = True,
) -> 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("捐赠单不存在或无权操作")
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
if k == "donation_date":
setattr(obj, k, _normalize_yyyymmdd_date(val))
else:
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()
if "party" in raw:
from app.services.party_customer_sync import sync_party_customers
party_raw = _normalize_fields(raw["party"] or {})
if sync_customers:
party_raw = sync_party_customers(
db,
party_raw,
tenant_no=tenant_no,
user_id=user_id or "",
commit=commit,
create_dept_id=create_dept_id,
)
_create_party(
db, donation_id, tenant_no, user_id, party_raw, now,
create_dept_id=create_dept_id,
)
if "collection" in raw:
coll_raw = _normalize_fields(raw["collection"] or {})
_create_collection(
db, donation_id, tenant_no, user_id, coll_raw, now,
create_dept_id=create_dept_id,
)
# samples upsert
if "samples" in raw:
existing_samples = list_samples(db, donation_id, tenant_no)
samples_raw = raw["samples"] or []
validate_persisted_sample_codes_immutable(existing_samples, samples_raw)
old_id_to_code = {
s.id: (s.sample_code or "").strip()
for s in existing_samples
if (s.sample_code or "").strip()
}
new_samples = _create_samples(
db, donation_id, tenant_no, user_id, samples_raw, now,
create_dept_id=create_dept_id,
)
kept_ids = {s.id for s in new_samples}
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={
sid: code for sid, code in old_id_to_code.items() if sid not in kept_ids
},
new_code_to_id=new_code_to_id,
user_id=user_id,
)
else:
samples_raw = None
new_samples = None
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,
)
if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
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
# ---------------------------------------------------------------------------
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()
assert_protocol_status_transition(current, ns, kind="donation")
now = datetime.now()
if isinstance(time_val, str):
time_val = parse_datetime(time_val)
effective_time = time_val or now
change_desc = (desc or "").strip() or None
normalized_void_type: Optional[str] = None
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 = change_desc
elif ns == "yzf":
obj.void_time = effective_time
obj.void_desc = change_desc
from app.crud.dict_data import normalize_void_type
normalized_void_type = normalize_void_type(db, value=void_type or "", tenant_no=tenant_no)
obj.void_type = normalized_void_type
elif ns == "ygq":
obj.expire_time = effective_time
obj.expire_desc = change_desc
elif ns == "zckc":
obj.to_storage_time = effective_time
obj.to_storage_desc = change_desc
change_type = _DONATION_STATUS_CHANGE_TYPE.get(ns)
if change_type:
append_donation_status_change(
db,
research_donation_id=obj.id,
change_type=change_type,
from_status=current,
to_status=ns,
change_time=effective_time,
change_desc=change_desc,
void_type=normalized_void_type if ns == "yzf" else None,
tenant_no=tenant_no,
user_id=user_id,
)
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
monthly_verify_no = allocate_monthly_verify_no(
db, tenant_no=tenant_no, create_time=now
)
# 创建主记录
donation = BizResearchDonation(
id=generate_uuid(),
donation_no=donation_no,
monthly_verify_no=monthly_verify_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(转科研)
from_status = (proto.protocol_status or "").strip()
to_research_desc = f"已转入科研捐赠 {donation_no}"
proto.protocol_status = "zky"
proto.to_research_time = now
proto.to_research_desc = to_research_desc
proto.update_by = user_id
proto.update_time = now
db.add(proto)
append_protocol_status_change(
db,
collection_protocol_id=protocol_id,
change_type="to_research",
from_status=from_status,
to_status="zky",
change_time=now,
change_desc=to_research_desc,
tenant_no=tenant_no,
user_id=user_id,
)
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
from_status = (donation.donation_status or "").strip()
to_storage_desc = f"已转细胞存储 {protocol_no}"
donation.donation_status = "zckc"
donation.to_storage_time = now
donation.to_storage_desc = to_storage_desc
donation.update_by = user_id
donation.update_time = now
db.add(donation)
append_donation_status_change(
db,
research_donation_id=donation_id,
change_type="to_storage",
from_status=from_status,
to_status="zckc",
change_time=now,
change_desc=to_storage_desc,
tenant_no=tenant_no,
user_id=user_id,
)
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 _apply_single_child_fields(obj, raw, str_fields, int_fields=(), dec_fields=(), date_fields=(), dt_fields=()):
obj.remark = (raw.get("remark") or "").strip() or None
for key in str_fields:
setattr(obj, key, ((raw.get(key) or "").strip() or None) if key in raw else None)
for key in int_fields:
setattr(obj, key, raw.get(key) if key in raw else None)
for key in dec_fields:
setattr(obj, key, _optional_decimal(raw.get(key)) if key in raw else None)
for key in date_fields:
setattr(obj, key, optional_date(raw.get(key)) if key in raw else None)
for key in dt_fields:
if key not in raw:
setattr(obj, key, None)
continue
val = raw.get(key)
if isinstance(val, str):
val = parse_datetime(val)
setattr(obj, key, val)
def _create_party(
db: Session,
donation_id: str,
tenant_no: str,
user_id: Optional[str],
raw: dict,
now: datetime,
create_dept_id: str = "",
) -> BizResearchDonationParty:
def build_row(row_id):
return BizResearchDonationParty(
id=row_id,
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.order_num = 0
_apply_single_child_fields(
row,
item,
_PARTY_STR_FIELDS,
_PARTY_INT_FIELDS,
_PARTY_DEC_FIELDS,
_PARTY_DATE_FIELDS,
_PARTY_DT_FIELDS,
)
rows = sync_child_rows(
db,
BizResearchDonationParty,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
items=[raw or {}],
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda _item: "party",
row_natural_key=lambda _row: "party",
duplicate_message="当事人信息重复",
foreign_message="当事人信息不属于当前捐赠单",
)
db.flush()
return rows[0]
_COLL_STR_FIELDS = [
"region_id",
"hospital_id",
"medical_staff",
"sales_user_id",
"region_manager_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,
create_dept_id: str = "",
) -> BizResearchDonationCollection:
def build_row(row_id):
return BizResearchDonationCollection(
id=row_id,
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
def apply_row(row, item, _idx, _now):
row.order_num = 0
_apply_single_child_fields(
row,
item,
_COLL_STR_FIELDS,
dt_fields=_COLL_DT_FIELDS,
)
rows = sync_child_rows(
db,
BizResearchDonationCollection,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
items=[raw or {}],
user_id=user_id,
now=now,
build_row=build_row,
apply_fields=apply_row,
natural_key=lambda _item: "collection",
row_natural_key=lambda _row: "collection",
duplicate_message="采集信息重复",
foreign_message="采集信息不属于当前捐赠单",
)
db.flush()
return rows[0]
_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)
_retarget_protocol_sample_id(
db,
BizProductionWorkOrder,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
)
_retarget_protocol_sample_id(
db,
BizQcSmsNotification,
old_ids=old_ids,
old_id_to_code=old_id_to_code,
new_code_to_id=new_code_to_id,
tenant_no=tenant_no,
user_id=user_id,
now=now,
del_flag_alive="0",
)
def _apply_donation_sample_fields(obj, raw, *, order_num, user_id, now) -> None:
obj.order_num = order_num
obj.remark = (raw.get("remark") or "").strip() or None
obj.update_by = user_id
obj.update_time = now
obj.del_flag = 0
for key in _SAMPLE_STR_FIELDS:
if key == "sample_status":
continue
if key in raw:
setattr(obj, key, (raw[key] 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 key in _SAMPLE_DEC_FIELDS:
if key in raw and raw[key] is not None:
setattr(obj, key, Decimal(str(raw[key])))
for key in _SAMPLE_INT_FIELDS:
if key in raw:
setattr(obj, key, raw[key])
apply_sample_special_fields(obj, raw)
def _create_samples(
db: Session,
donation_id: str,
tenant_no: str,
user_id: Optional[str],
samples: list,
now: datetime,
create_dept_id: str = "",
) -> List[BizResearchDonationSample]:
existing = list_samples(db, donation_id, tenant_no)
by_id = {sample.id: sample for sample in existing}
by_code: Dict[str, List[BizResearchDonationSample]] = {}
for sample in existing:
code = (sample.sample_code or "").strip()
if code:
by_code.setdefault(code, []).append(sample)
claimed = set()
planned = []
for idx, raw_item in enumerate(samples or []):
raw = _normalize_fields(raw_item)
validate_sample_special_raw(raw, index=idx)
sid = (raw.get("id") or "").strip()
code = (raw.get("sample_code") or "").strip()
row = by_id.get(sid) if sid else None
if row is None and sid:
row = _owned_child_row(
db,
BizResearchDonationSample,
sid,
parent_attr="research_donation_id",
parent_id=donation_id,
tenant_no=tenant_no,
message="样本不属于当前捐赠单",
)
if row is None and code:
for candidate in by_code.get(code, []):
if candidate.id not in claimed:
row = candidate
break
if row is not None:
if row.id in claimed:
raise ValueError("样本编号重复")
claimed.add(row.id)
planned.append((idx, raw, row))
kept_codes = {
(sample.sample_code or "").strip()
for sample in existing
if sample.id in claimed and (sample.sample_code or "").strip()
}
validate_sample_codes_not_used(
db,
tenant_no=tenant_no,
samples=[
raw
for _idx, raw, row in planned
if row is None or (raw.get("sample_code") or "").strip() not in kept_codes
],
exclude_donation_id=donation_id,
)
result = []
assigned_codes: List[str] = []
kept_transport_ids = set()
for idx, raw, obj in planned:
code = (raw.get("sample_code") or "").strip()
if obj is None and 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)
if obj is None:
obj = BizResearchDonationSample(
id=(raw.get("id") or "").strip() or generate_uuid(),
research_donation_id=donation_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=create_dept_id,
create_time=now,
)
_apply_donation_sample_fields(
obj, raw, order_num=idx, user_id=user_id, now=now
)
obj.research_donation_id = donation_id
obj.tenant_no = tenant_no
db.add(obj)
db.flush()
result.append(obj)
transport_raw = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
if not transport_raw and raw.get("eta_time"):
transport_raw = {"eta_time": raw.get("eta_time")}
elif isinstance(transport_raw, dict) and raw.get("eta_time") and not (
transport_raw.get("eta_time") or transport_raw.get("etaTime")
):
transport_raw = {**transport_raw, "eta_time": raw.get("eta_time")}
if transport_raw:
transport_row = _create_transport(
db,
donation_id,
obj.id,
tenant_no,
user_id,
transport_raw,
idx,
now,
sample_code=obj.sample_code,
)
if transport_row is not None:
kept_transport_ids.add(transport_row.id)
for sample in existing:
if sample.id in claimed:
continue
sample.del_flag = 1
sample.update_by = user_id
sample.update_time = now
db.add(sample)
for transport in (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id == donation_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.all()
):
if transport.id in kept_transport_ids:
continue
transport.del_flag = 1
transport.update_by = user_id
transport.update_time = now
db.add(transport)
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()
eta_time = tnorm.get("eta_time")
if isinstance(eta_time, str):
eta_time = parse_datetime(eta_time)
if not transport_type and not eta_time:
return None
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
existing = (
db.query(BizResearchDonationSampleTransport)
.filter(
BizResearchDonationSampleTransport.research_donation_id == donation_id,
BizResearchDonationSampleTransport.sample_id == sample_id,
BizResearchDonationSampleTransport.tenant_no == tenant_no,
BizResearchDonationSampleTransport.del_flag == 0,
)
.order_by(BizResearchDonationSampleTransport.create_time.asc())
.all()
)
obj = existing[0] if existing else BizResearchDonationSampleTransport(
id=generate_uuid(),
research_donation_id=donation_id,
sample_id=sample_id,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
)
obj.sample_code = sample_code or None
obj.transport_type = transport_type
obj.express_company_id = (tnorm.get("express_company_id") or "").strip() or None
obj.waybill_no = (tnorm.get("waybill_no") or "").strip() or None
obj.dest_site_id = (tnorm.get("dest_site_id") or "").strip() or None
obj.express_fee = express_fee
obj.eta_time = eta_time
obj.carrier_name = (tnorm.get("carrier_name") or "").strip() or None
obj.carrier_phone = (tnorm.get("carrier_phone") or "").strip() or None
obj.order_num = order_num
obj.remark = (tnorm.get("remark") or "").strip() or None
obj.del_flag = 0
obj.update_by = user_id
obj.update_time = now
db.add(obj)
for extra in existing[1:]:
extra.del_flag = 1
extra.update_by = user_id
extra.update_time = now
db.add(extra)
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)