"""采集协议单 / 审核 / 缴费明细 CRUD(细胞存储 2.2.6~2.2.10)""" from __future__ import annotations import math import calendar from datetime import datetime, timedelta 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 from app.crud.base import camel_to_snake from app.crud.protocol_donation_convert import ( assert_biz_no_available, get_partner_sample_codes, ) from app.models.research_donation import BizResearchDonation, BizResearchDonationSample from app.models.models import ( BizAttachment, BizCollectionProtocol, BizProtocolAudit, BizProtocolCollectionInfo, BizProtocolFullmoonTodo, BizProtocolPartyCustomer, BizProtocolPayment, BizProtocolPaymentDetail, BizProtocolSample, BizProtocolSampleTransport, BizProtocolStorageCert, BizCollectionRegion, BizCollectionHospital, SysUser, ) def get_protocol_nos_used_in_protocols( db: Session, *, tenant_no: str, protocol_nos: List[str], ) -> set: """返回已在采集协议单或科研捐赠单中占用的协议编号集合(同租户、未删除)。""" nos = list({(n or "").strip() for n in protocol_nos if (n or "").strip()}) if not nos: return set() used: set = set() proto_rows = ( db.query(BizCollectionProtocol.protocol_no) .filter( BizCollectionProtocol.tenant_no == tenant_no, BizCollectionProtocol.del_flag == 0, BizCollectionProtocol.protocol_no.in_(nos), ) .all() ) used.update(r[0] for r in proto_rows if r[0]) donation_rows = ( db.query(BizResearchDonation.donation_no) .filter( BizResearchDonation.tenant_no == tenant_no, BizResearchDonation.del_flag == 0, BizResearchDonation.donation_no.in_(nos), ) .all() ) used.update(r[0] for r in donation_rows if r[0]) return used def get_sample_codes_used_in_protocol_samples( db: Session, *, tenant_no: str, sample_codes: List[str], ) -> set: """返回已在采集协议单或科研捐赠单样本表中占用的样本编码集合(同租户、未删除)。""" codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()}) if not codes: return set() used: set = set() proto_rows = ( db.query(BizProtocolSample.sample_code) .filter( BizProtocolSample.tenant_no == tenant_no, BizProtocolSample.del_flag == 0, BizProtocolSample.sample_code.in_(codes), ) .all() ) used.update(r[0] for r in proto_rows if r[0]) donation_rows = ( db.query(BizResearchDonationSample.sample_code) .filter( BizResearchDonationSample.tenant_no == tenant_no, BizResearchDonationSample.del_flag == 0, BizResearchDonationSample.sample_code.in_(codes), ) .all() ) used.update(r[0] for r in donation_rows if r[0]) return used def validate_sample_codes_not_used( db: Session, *, tenant_no: str, samples: list, exclude_protocol_id: Optional[str] = None, exclude_donation_id: Optional[str] = None, ) -> None: """创建/更新样本行前校验:样本编码不得已被任一业务单占用(转移配对单豁免)。""" codes: List[str] = [] for raw_item in samples or []: raw = _normalize_fields(raw_item) c = (raw.get("sample_code") or "").strip() if c: codes.append(c) if not codes: return used = get_sample_codes_used_in_protocol_samples( db, tenant_no=tenant_no, sample_codes=codes ) exempt = get_partner_sample_codes( db, tenant_no, protocol_id=exclude_protocol_id, donation_id=exclude_donation_id, ) used -= exempt conflicts = set(codes) & used if conflicts: first = sorted(conflicts)[0] raise ValueError(f"样本编码「{first}」已被占用") def total_pages(total: int, page_size: int) -> int: if total <= 0: return 0 return int(math.ceil(total / float(page_size))) def _fmt_dt(t: Optional[datetime]) -> str: return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" 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)) def _today_yyyymmdd() -> str: return datetime.now().strftime("%Y%m%d") def _normalize_yyyymmdd_date(val: Any) -> Optional[str]: """将 YYYYMMDD / YYYY-MM-DD 等格式规范为 8 位日期字符串。""" if val is None: return None s = str(val).strip() if not s: return None compact = s.replace("-", "").replace("/", "")[:8] if len(compact) == 8 and compact.isdigit(): return compact return None _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 _pop_attachment_urls(raw: Dict[str, Any], *keys: str) -> Optional[str]: """从保存载荷中取出附件字段(支持 snake/camel、URL 串或附件对象列表)。""" for key in keys: if key not in raw: continue urls = attachments_to_urls(raw.pop(key)) if urls: return urls return None def attachments_to_urls(attachments: Any) -> Optional[str]: """附件列表或字符串 → 逗号分隔 URL(MinIO)。""" if attachments is None: return None if isinstance(attachments, str): return attachments.strip() or None if isinstance(attachments, list): urls: List[str] = [] for item in attachments: if isinstance(item, dict): u = (item.get("fileUrl") or item.get("file_url") or "").strip() if u: urls.append(u) elif isinstance(item, str) and item.strip(): urls.append(item.strip()) return ",".join(urls) if urls else None return None def _normalize_attachment_url_key(url: str) -> str: u = (url or "").strip() if not u: return "" if "?" in u: u = u.split("?", 1)[0] return u.lstrip("/") def _format_attachment_file_size(size_bytes: int) -> str: if size_bytes < 1024: return f"{size_bytes} B" if size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" return f"{size_bytes / (1024 * 1024):.1f} MB" def _lookup_attachment_by_url( db: Session, url: str, tenant_no: Optional[str], ) -> Optional[BizAttachment]: key = _normalize_attachment_url_key(url) if not key: return None q = db.query(BizAttachment).filter(BizAttachment.del_flag == 0) if tenant_no: q = q.filter(BizAttachment.tenant_no == tenant_no) row = q.filter(BizAttachment.file_url == key).first() if row: return row tail = key.rsplit("/", 1)[-1] if tail: row = ( q.filter(BizAttachment.file_url.like(f"%/{tail}")) .order_by(BizAttachment.create_time.desc()) .first() ) return row def urls_to_attachment_list( urls_str: Optional[str], db: Optional[Session] = None, tenant_no: Optional[str] = None, ) -> List[Dict[str, Any]]: """逗号分隔 URL → 前端附件卡片结构;有 db 时从 biz_attachment 取原始文件名。""" if not urls_str: return [] result: List[Dict[str, Any]] = [] for url in urls_str.split(","): u = url.strip() if not u: continue att = _lookup_attachment_by_url(db, u, tenant_no) if db else None if att: size = att.file_size or 0 result.append( { "id": att.id, "fileUrl": u, "fileName": att.original_name or u.rsplit("/", 1)[-1], "fileSize": size, "fileSizeDisplay": _format_attachment_file_size(size), "fileType": att.file_type or "", } ) else: name = u.rsplit("/", 1)[-1] if "/" in u else u result.append({"fileUrl": u, "fileName": name, "fileSize": 0}) return result 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 _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]: return { "id": p.id, "protocolNoId": p.protocol_no_id or "", "protocolNo": p.protocol_no or "", "protocolStatus": p.protocol_status or "", "protocolAttachments": p.protocol_attachments or "", "registerDate": (p.register_date or "").strip() if p.register_date else "", "effectiveTime": _fmt_dt(p.effective_time), "effectiveDesc": p.effective_desc or "", "expireTime": _fmt_dt(p.expire_time), "expireDesc": p.expire_desc or "", "voidTime": _fmt_dt(p.void_time), "voidDesc": p.void_desc or "", "toResearchTime": _fmt_dt(p.to_research_time), "toResearchDesc": p.to_research_desc or "", "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 _payment_to_dict(p: BizProtocolPayment) -> Dict[str, Any]: return { "id": p.id, "collectionProtocolId": p.collection_protocol_id, "payMode": p.pay_mode or "", "contractAmount": _dec(p.contract_amount), "installmentCount": p.installment_count, "installmentIntervalMonths": p.installment_interval_months, "lumpSumDeadline": (p.lump_sum_deadline or "").strip() if p.lump_sum_deadline else "", "installmentStartDate": (p.installment_start_date or "").strip() if p.installment_start_date else "", "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 _detail_to_dict( d: BizProtocolPaymentDetail, *, protocol_no: str = "", collection_protocol_id: str = "", protocol_status: str = "", pay_mode: str = "", mother_name: str = "", mother_id_card: str = "", newborn_name: str = "", newborn_id_card: str = "", ) -> Dict[str, Any]: return { "id": d.id, "paymentId": d.payment_id, "collectionProtocolId": collection_protocol_id, "protocolNo": protocol_no, "protocolStatus": protocol_status, "payMode": pay_mode, "motherName": mother_name, "motherIdCard": mother_id_card, "newbornName": newborn_name, "newbornIdCard": newborn_id_card, "transferType": d.transfer_type or "", "dueAmount": _dec(d.due_amount), "transferAmount": _dec(d.transfer_amount), "transferTime": _fmt_dt(d.transfer_time), "transferAttachments": d.transfer_attachments or "", "financeConfirmAttachments": d.finance_confirm_attachments or "", "financeRemark": d.finance_remark or "", "refundFinanceConfirmAttachments": d.refund_finance_confirm_attachments or "", "refundFinanceRemark": d.refund_finance_remark or "", "payStatus": d.pay_status or "", "installmentNo": d.installment_no, "installmentDueDate": (d.installment_due_date or "").strip() if d.installment_due_date else "", "refundAmount": _dec(d.refund_amount), "refundRemark": d.refund_remark or "", "refundAttachments": d.refund_attachments 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: BizProtocolAudit, *, protocol_no: str = "", auditor_user_name: str = "", ) -> Dict[str, Any]: return { "id": a.id, "collectionProtocolId": a.collection_protocol_id, "protocolNo": protocol_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_customer_to_dict(p: BizProtocolPartyCustomer) -> Dict[str, Any]: return { "id": p.id, "collectionProtocolId": p.collection_protocol_id, "customerId": p.customer_id or "", "motherName": p.mother_name or "", "motherPhone": p.mother_phone or "", "motherIdCard": p.mother_id_card or "", "motherBirthYm": (p.mother_birth_ym or "").strip() if p.mother_birth_ym else "", "motherEthnic": p.mother_ethnic or "", "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 "", "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_info_to_dict(c: BizProtocolCollectionInfo) -> Dict[str, Any]: return { "id": c.id, "collectionProtocolId": c.collection_protocol_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: BizProtocolSample) -> Dict[str, Any]: return { "id": s.id, "collectionProtocolId": s.collection_protocol_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 "", "price": _dec(s.price), "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 "", "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: BizProtocolSampleTransport) -> Dict[str, Any]: return { "id": t.id, "collectionProtocolId": t.collection_protocol_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), } def _storage_cert_to_dict(s: BizProtocolStorageCert) -> Dict[str, Any]: return { "id": s.id, "collectionProtocolId": s.collection_protocol_id, "makeStatus": s.make_status or "0", "certImageUrls": s.cert_image_urls or "", "makeDate": (s.make_date or "").strip() if s.make_date else "", "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 list_collection_protocols( db: Session, *, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, protocol_status: Optional[str] = None, tenant_no: Optional[str] = None, scope_collector_user_id: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: def _filtered(): q0 = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.del_flag == 0) if tenant_no: q0 = q0.filter(BizCollectionProtocol.tenant_no == tenant_no) if scope_collector_user_id: q0 = q0.filter( BizCollectionProtocol.collector_user_id == scope_collector_user_id ) if protocol_status: q0 = q0.filter(BizCollectionProtocol.protocol_status == protocol_status.strip()) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q0 = q0.filter( or_( BizCollectionProtocol.protocol_no.like(like), BizCollectionProtocol.remark.like(like), ) ) return q0 total = _filtered().order_by(None).with_entities(func.count(BizCollectionProtocol.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( _filtered() .order_by(BizCollectionProtocol.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) # 批量加载子表数据,避免 N+1 查询 protocol_ids = [r.id for r in rows] pc_map: Dict[str, Dict[str, Any]] = {} ci_map: Dict[str, Dict[str, Any]] = {} if protocol_ids: pc_rows = ( db.query(BizProtocolPartyCustomer) .filter( BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids), BizProtocolPartyCustomer.del_flag == 0, ) .all() ) for pc in pc_rows: pc_map[pc.collection_protocol_id] = _party_customer_to_dict(pc) ci_rows = ( db.query(BizProtocolCollectionInfo) .filter( BizProtocolCollectionInfo.collection_protocol_id.in_(protocol_ids), BizProtocolCollectionInfo.del_flag == 0, ) .all() ) for ci in ci_rows: ci_map[ci.collection_protocol_id] = _collection_info_to_dict(ci) # 批量解析地区/医院名称 region_ids = list({ci_map.get(pid, {}).get("regionId", "") for pid in protocol_ids} - {""}) hospital_ids = list({ci_map.get(pid, {}).get("hospitalId", "") for pid in protocol_ids} - {""}) region_name_map: Dict[str, str] = {} hospital_name_map: Dict[str, str] = {} if region_ids: for r in db.query(BizCollectionRegion.id, BizCollectionRegion.region_name).filter(BizCollectionRegion.id.in_(region_ids)).all(): region_name_map[r.id] = r.region_name or "" if hospital_ids: for h in db.query(BizCollectionHospital.id, BizCollectionHospital.hospital_name).filter(BizCollectionHospital.id.in_(hospital_ids)).all(): hospital_name_map[h.id] = h.hospital_name or "" # 批量解析销售人员名称 sales_user_ids = list({ci_map.get(pid, {}).get("salesUserId", "") for pid in protocol_ids} - {""}) collector_user_ids = list({_protocol_to_dict(r).get("collectorUserId", "") for r in rows} - {""}) all_user_ids = list(set(sales_user_ids + collector_user_ids)) user_name_map: Dict[str, str] = {} if all_user_ids: for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(all_user_ids)).all(): user_name_map[u.id] = u.nick_name or "" # 合并主表 + 子表数据 result = [] for r in rows: d = _protocol_to_dict(r) pc = pc_map.get(r.id) ci = ci_map.get(r.id) d["collectorUserName"] = user_name_map.get(d.get("collectorUserId", ""), "") if pc: d["motherName"] = pc.get("motherName", "") d["motherPhone"] = pc.get("motherPhone", "") d["motherIdCard"] = pc.get("motherIdCard", "") d["motherBirthYm"] = pc.get("motherBirthYm", "") d["motherEthnic"] = pc.get("motherEthnic", "") d["fatherName"] = pc.get("fatherName", "") d["fatherPhone"] = pc.get("fatherPhone", "") d["newbornDeliveryTime"] = pc.get("newbornDeliveryTime", "") d["newbornSex"] = pc.get("newbornSex", "") d["deliveryMode"] = pc.get("deliveryMode", "") d["birthOrder"] = pc.get("birthOrder") d["newbornWeightGram"] = pc.get("newbornWeightGram") else: d["motherName"] = "" d["motherPhone"] = "" d["motherIdCard"] = "" d["motherBirthYm"] = "" d["motherEthnic"] = "" d["fatherName"] = "" d["fatherPhone"] = "" d["newbornDeliveryTime"] = "" d["newbornSex"] = "" d["deliveryMode"] = "" d["birthOrder"] = None d["newbornWeightGram"] = None 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"] = "" result.append(d) return result, int(total) def get_collection_protocol( db: Session, protocol_id: str, tenant_no: str, *, scope_collector_user_id: Optional[str] = None, ) -> Optional[BizCollectionProtocol]: filters = [ BizCollectionProtocol.id == protocol_id, BizCollectionProtocol.del_flag == 0, BizCollectionProtocol.tenant_no == tenant_no, ] if scope_collector_user_id: filters.append( BizCollectionProtocol.collector_user_id == scope_collector_user_id ) return db.query(BizCollectionProtocol).filter(and_(*filters)).first() def get_payment_by_protocol_id(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolPayment]: return ( db.query(BizProtocolPayment) .filter( and_( BizProtocolPayment.collection_protocol_id == collection_protocol_id, BizProtocolPayment.tenant_no == tenant_no, BizProtocolPayment.del_flag == 0, ) ) .first() ) def get_protocol_detail_bundle( db: Session, protocol_id: str, tenant_no: str, *, scope_collector_user_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: p = get_collection_protocol( db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id ) if not p: return None pay = get_payment_by_protocol_id(db, protocol_id, tenant_no) pc = get_party_customer(db, protocol_id, tenant_no) ci = get_collection_info(db, protocol_id, tenant_no) s_rows = list_samples(db, protocol_id, tenant_no) sc = get_storage_cert(db, protocol_id, tenant_no) protocol_dict = _protocol_to_dict(p) ci_dict = _collection_info_to_dict(ci) if ci else None user_ids: List[str] = [] if protocol_dict.get("collectorUserId"): user_ids.append(str(protocol_dict["collectorUserId"])) if ci_dict and ci_dict.get("salesUserId"): user_ids.append(str(ci_dict["salesUserId"])) user_name_map = _user_nick_name_map(db, user_ids) protocol_dict["collectorUserName"] = user_name_map.get( str(protocol_dict.get("collectorUserId") or ""), "" ) if ci_dict: ci_dict["salesUserName"] = user_name_map.get(str(ci_dict.get("salesUserId") or ""), "") region_id = str(ci_dict.get("regionId") or "") if region_id: r = ( db.query(BizCollectionRegion.region_name) .filter(BizCollectionRegion.id == region_id) .first() ) ci_dict["regionName"] = r.region_name if r else "" hospital_id = str(ci_dict.get("hospitalId") or "") if hospital_id: h = ( db.query(BizCollectionHospital.hospital_name) .filter(BizCollectionHospital.id == hospital_id) .first() ) ci_dict["hospitalName"] = h.hospital_name if h else "" out: Dict[str, Any] = {"protocol": protocol_dict} out["payment"] = _payment_to_dict(pay) if pay else None out["partyCustomer"] = _party_customer_to_dict(pc) if pc else None out["collectionInfo"] = ci_dict transport_rows = list_sample_transports(db, protocol_id, tenant_no) transport_by_sample = {t.sample_id: _transport_to_dict(t) for t in transport_rows} from app.crud import sample_reception as sample_reception_svc sample_ids = [s.id for s in s_rows] reception_by_sample = sample_reception_svc.get_receptions_map_for_samples( db, sample_ids, tenant_no ) from app.crud import qc_work_order as qc_work_order_svc qc_by_sample = qc_work_order_svc.get_qc_info_map_for_samples( db, sample_ids, tenant_no ) samples_out: List[Dict[str, Any]] = [] for s in s_rows: item = _sample_to_dict(s) item["transport"] = transport_by_sample.get(s.id) item["sampleReception"] = reception_by_sample.get(s.id) item["qcInfo"] = qc_by_sample.get(s.id) samples_out.append(item) out["samples"] = samples_out out["storageCert"] = _storage_cert_to_dict(sc) if sc else None out["attachments"] = urls_to_attachment_list(p.protocol_attachments, db, tenant_no) if pay: detail_rows = list_payment_details(db, pay.id, tenant_no) details_out: List[Dict[str, Any]] = [] for d in detail_rows: item = _detail_to_dict( d, protocol_no=p.protocol_no or "", collection_protocol_id=protocol_id, ) item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no) item["financeConfirmAttachmentFiles"] = urls_to_attachment_list( d.finance_confirm_attachments, db, tenant_no ) item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list( d.refund_finance_confirm_attachments, db, tenant_no ) item["refundAttachmentFiles"] = urls_to_attachment_list( d.refund_attachments, db, tenant_no ) details_out.append(item) out["paymentDetails"] = details_out else: out["paymentDetails"] = [] return out def list_audits( db: Session, *, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, audit_status: Optional[str] = None, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: q_base = ( db.query(BizProtocolAudit, BizCollectionProtocol.protocol_no) .join( BizCollectionProtocol, and_( BizProtocolAudit.collection_protocol_id == BizCollectionProtocol.id, BizCollectionProtocol.del_flag == 0, ), ) .filter(BizProtocolAudit.del_flag == 0) ) if tenant_no: q_base = q_base.filter(BizProtocolAudit.tenant_no == tenant_no) if audit_status: q_base = q_base.filter(BizProtocolAudit.audit_status == audit_status.strip()) kw = (keyword or "").strip() if kw: like = f"%{kw}%" q_base = q_base.filter( or_(BizCollectionProtocol.protocol_no.like(like), BizProtocolAudit.audit_remark.like(like)) ) total = q_base.order_by(None).with_entities(func.count(BizProtocolAudit.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizProtocolAudit.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: Dict[str, str] = {} if auditor_ids: for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(auditor_ids)).all(): auditor_name_map[u.id] = u.nick_name or "" out = [ _audit_to_dict( a, protocol_no=pn or "", auditor_user_name=auditor_name_map.get(a.auditor_user_id or "", ""), ) for a, pn in rows ] return out, int(total) def create_audit( db: Session, *, collection_protocol_id: str, audit_status: str, audit_remark: Optional[str], tenant_no: str, user_id: Optional[str], ) -> BizProtocolAudit: p = get_collection_protocol(db, collection_protocol_id, tenant_no) if not p: raise ValueError("协议单不存在") st = (audit_status or "").strip() if st not in ("shtg", "shth"): raise ValueError("审核状态无效") obj = BizProtocolAudit( id=generate_uuid(), collection_protocol_id=collection_protocol_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=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) db.add(obj) db.commit() db.refresh(obj) return obj def _detail_query_with_protocol( db: Session, tenant_no: str, pay_status_filter: str, ): return ( db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol) .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id) .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id) .filter( BizProtocolPaymentDetail.del_flag == 0, BizProtocolPayment.del_flag == 0, BizCollectionProtocol.del_flag == 0, BizProtocolPaymentDetail.tenant_no == tenant_no, BizProtocolPaymentDetail.pay_status == pay_status_filter, ) ) def _rows_to_payment_detail_dicts( db: Session, rows: List[Any], tenant_no: str, *, post_process: Optional[Any] = None, ) -> List[Dict[str, Any]]: """缴费明细行 → 前端字典(含协议、客户、转账附件)。""" protocol_ids = [ pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id ] pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids) out: List[Dict[str, Any]] = [] for d, pay, prot in rows: cpid = pay.collection_protocol_id if pay else "" pc = pc_map.get(cpid) item = _detail_to_dict( d, protocol_no=(prot.protocol_no if prot else "") or "", collection_protocol_id=cpid, protocol_status=(prot.protocol_status if prot else "") or "", pay_mode=(pay.pay_mode if pay else "") or "", mother_name=(pc.mother_name if pc else "") or "", mother_id_card=(pc.mother_id_card if pc else "") or "", newborn_name=(pc.newborn_name if pc else "") or "", newborn_id_card=(pc.newborn_id_card if pc else "") or "", ) item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no) if post_process: post_process(item, d) out.append(item) return out def list_receipt_confirm_details( db: Session, *, page: int = 1, page_size: int = 10, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: if not tenant_no: return [], 0 q_base = _detail_query_with_protocol(db, tenant_no, "jfqrz") total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizProtocolPaymentDetail.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) return _rows_to_payment_detail_dicts(db, rows, tenant_no), int(total) def list_receipt_confirmed_details( db: Session, *, page: int = 1, page_size: int = 10, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: """财务已确认收款(yjf),按确认时间倒序。""" if not tenant_no: return [], 0 q_base = _detail_query_with_protocol(db, tenant_no, "yjf") total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizProtocolPaymentDetail.update_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) def _add_finance_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: item["financeConfirmAttachmentFiles"] = urls_to_attachment_list( d.finance_confirm_attachments, db, tenant_no ) return ( _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_finance_files), int(total), ) def confirm_receipt( db: Session, *, detail_id: str, tenant_no: str, user_id: Optional[str], finance_confirm_attachments: Optional[str] = None, finance_remark: Optional[str] = None, ) -> BizProtocolPaymentDetail: d = ( db.query(BizProtocolPaymentDetail) .filter( and_( BizProtocolPaymentDetail.id == detail_id, BizProtocolPaymentDetail.tenant_no == tenant_no, BizProtocolPaymentDetail.del_flag == 0, ) ) .first() ) if not d: raise ValueError("缴费明细不存在") if d.pay_status != "jfqrz": if d.pay_status == "yjf": raise ValueError("该明细已确认收款") raise ValueError("当前状态不可确认收款") d.pay_status = "yjf" if finance_confirm_attachments is not None: d.finance_confirm_attachments = finance_confirm_attachments.strip() or None if finance_remark is not None: d.finance_remark = finance_remark.strip() or None d.update_by = user_id d.update_time = datetime.now() db.add(d) db.commit() db.refresh(d) return d def list_refund_confirm_details( db: Session, *, page: int = 1, page_size: int = 10, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: if not tenant_no: return [], 0 q_base = _detail_query_with_protocol(db, tenant_no, "tfqrz") total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizProtocolPaymentDetail.create_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) def _add_refund_apply_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: item["refundAttachmentFiles"] = urls_to_attachment_list( d.refund_attachments, db, tenant_no ) return ( _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_apply_files), int(total), ) def list_refund_confirmed_details( db: Session, *, page: int = 1, page_size: int = 10, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: """财务已确认退费(ytf),按确认时间倒序。""" if not tenant_no: return [], 0 q_base = _detail_query_with_protocol(db, tenant_no, "ytf") total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q_base.order_by(BizProtocolPaymentDetail.update_time.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) def _add_refund_confirmed_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None: item["refundAttachmentFiles"] = urls_to_attachment_list( d.refund_attachments, db, tenant_no ) item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list( d.refund_finance_confirm_attachments, db, tenant_no ) return ( _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_confirmed_files), int(total), ) def confirm_refund( db: Session, *, detail_id: str, tenant_no: str, user_id: Optional[str], refund_finance_confirm_attachments: Optional[str] = None, refund_finance_remark: Optional[str] = None, ) -> BizProtocolPaymentDetail: d = ( db.query(BizProtocolPaymentDetail) .filter( and_( BizProtocolPaymentDetail.id == detail_id, BizProtocolPaymentDetail.tenant_no == tenant_no, BizProtocolPaymentDetail.del_flag == 0, ) ) .first() ) if not d: raise ValueError("缴费明细不存在") if d.pay_status != "tfqrz": if d.pay_status == "ytf": raise ValueError("该明细已确认退费") raise ValueError("当前状态不可确认退费") d.pay_status = "ytf" if refund_finance_confirm_attachments is not None: d.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None if refund_finance_remark is not None: d.refund_finance_remark = refund_finance_remark.strip() or None d.update_by = user_id d.update_time = datetime.now() db.add(d) db.commit() db.refresh(d) return d def _get_payment_detail_or_raise( db: Session, *, detail_id: str, tenant_no: str, ) -> BizProtocolPaymentDetail: d = ( db.query(BizProtocolPaymentDetail) .filter( and_( BizProtocolPaymentDetail.id == detail_id, BizProtocolPaymentDetail.tenant_no == tenant_no, BizProtocolPaymentDetail.del_flag == 0, ) ) .first() ) if not d: raise ValueError("缴费明细不存在") return d def _apply_submit_receipt_finance( d: BizProtocolPaymentDetail, *, user_id: Optional[str], ) -> None: if d.pay_status != "dfj": raise ValueError("仅待缴费状态可提交财务确认") d.pay_status = "jfqrz" d.update_by = user_id d.update_time = datetime.now() def submit_receipt_finance( db: Session, *, detail_id: str, tenant_no: str, user_id: Optional[str], ) -> BizProtocolPaymentDetail: d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) _apply_submit_receipt_finance(d, user_id=user_id) db.add(d) db.commit() db.refresh(d) return d def batch_submit_receipt_finance( db: Session, *, detail_ids: List[str], tenant_no: str, user_id: Optional[str], ) -> int: ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()] if not ids: raise ValueError("请选择缴费明细") for detail_id in ids: d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) _apply_submit_receipt_finance(d, user_id=user_id) db.add(d) db.commit() return len(ids) def _apply_submit_refund_finance( d: BizProtocolPaymentDetail, *, user_id: Optional[str], refund_amount: Any, refund_remark: Optional[str] = None, refund_attachments: Optional[str] = None, ) -> None: if d.pay_status != "yjf": raise ValueError("仅已缴费状态可提交退费财务确认") if refund_amount is None or Decimal(str(refund_amount)) <= 0: raise ValueError("请填写有效的退费金额") d.pay_status = "tfqrz" d.refund_amount = Decimal(str(refund_amount)) d.refund_remark = (refund_remark or "").strip() or None d.refund_attachments = (refund_attachments or "").strip() or None d.update_by = user_id d.update_time = datetime.now() def submit_refund_finance( db: Session, *, detail_id: str, tenant_no: str, user_id: Optional[str], refund_amount: Any, refund_remark: Optional[str] = None, refund_attachments: Optional[str] = None, ) -> BizProtocolPaymentDetail: d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) _apply_submit_refund_finance( d, user_id=user_id, refund_amount=refund_amount, refund_remark=refund_remark, refund_attachments=refund_attachments, ) db.add(d) db.commit() db.refresh(d) return d def batch_submit_refund_finance( db: Session, *, detail_ids: List[str], tenant_no: str, user_id: Optional[str], refund_amount: Any, refund_remark: Optional[str] = None, refund_attachments: Optional[str] = None, ) -> int: ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()] if not ids: raise ValueError("请选择缴费明细") for detail_id in ids: d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no) _apply_submit_refund_finance( d, user_id=user_id, refund_amount=refund_amount, refund_remark=refund_remark, refund_attachments=refund_attachments, ) db.add(d) db.commit() return len(ids) def _party_customer_map_by_protocol_ids( db: Session, protocol_ids: List[str], ) -> Dict[str, BizProtocolPartyCustomer]: if not protocol_ids: return {} rows = ( db.query(BizProtocolPartyCustomer) .filter( BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids), BizProtocolPartyCustomer.del_flag == 0, ) .all() ) return {r.collection_protocol_id: r for r in rows} def list_payment_details_page( db: Session, *, page: int = 1, page_size: int = 10, pay_status: Optional[str] = None, keyword: Optional[str] = None, transfer_type: Optional[str] = None, tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: if not tenant_no: return [], 0 q = ( db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol) .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id) .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id) .filter( BizProtocolPaymentDetail.del_flag == 0, BizProtocolPayment.del_flag == 0, BizCollectionProtocol.del_flag == 0, BizProtocolPaymentDetail.tenant_no == tenant_no, ) ) if pay_status: q = q.filter(BizProtocolPaymentDetail.pay_status == pay_status) if keyword and keyword.strip(): like = f"%{keyword.strip()}%" q = q.filter(BizCollectionProtocol.protocol_no.like(like)) if transfer_type and transfer_type.strip(): q = q.filter(BizProtocolPaymentDetail.transfer_type == transfer_type.strip()) total = q.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0 page = max(1, page) page_size = min(max(1, page_size), 100) rows = ( q.order_by(BizProtocolPaymentDetail.installment_due_date.asc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) protocol_ids = [ pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id ] pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids) out = [] for d, pay, prot in rows: cpid = pay.collection_protocol_id if pay else "" pc = pc_map.get(cpid) out.append( _detail_to_dict( d, protocol_no=(prot.protocol_no if prot else "") or "", collection_protocol_id=cpid, protocol_status=(prot.protocol_status if prot else "") or "", pay_mode=(pay.pay_mode if pay else "") or "", mother_name=(pc.mother_name if pc else "") or "", mother_id_card=(pc.mother_id_card if pc else "") or "", newborn_name=(pc.newborn_name if pc else "") or "", newborn_id_card=(pc.newborn_id_card if pc else "") or "", ) ) return out, int(total) def _classify_installment_warning(due: str, today: str) -> str: """overdue | warn | upcoming — due 为 YYYYMMDD""" if not due or len(due) != 8 or not due.isdigit(): return "unknown" if due < today: return "overdue" last_warn = (datetime.strptime(today, "%Y%m%d") + timedelta(days=15)).strftime("%Y%m%d") if today <= due <= last_warn: return "warn" return "upcoming" def list_installment_warning( db: Session, *, page: int = 1, page_size: int = 10, warning_type: str = "all", tenant_no: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], int]: if not tenant_no: return [], 0 today = _today_yyyymmdd() q0 = _detail_query_with_protocol(db, tenant_no, "dfj").filter( BizProtocolPaymentDetail.installment_no.isnot(None), ) # 拉取当前租户下候选(分期号非空);日期过滤在内存中完成以保证与 classify 一致 rows_all = q0.order_by(BizProtocolPaymentDetail.installment_due_date.asc()).all() flattened: List[Dict[str, Any]] = [] for d, pay, prot in rows_all: due_raw = (d.installment_due_date or "").strip() if d.installment_due_date else "" wt = _classify_installment_warning(due_raw, today) cpid = pay.collection_protocol_id if pay else "" item = _detail_to_dict( d, protocol_no=(prot.protocol_no if prot else "") or "", collection_protocol_id=cpid, protocol_status=(prot.protocol_status if prot else "") or "", pay_mode=(pay.pay_mode if pay else "") or "", ) item["warningType"] = wt wt_filter = (warning_type or "all").strip().lower() if wt_filter == "overdue" and wt != "overdue": continue if wt_filter == "warn" and wt != "warn": continue if wt_filter == "all": pass elif wt_filter not in ("all", "warn", "overdue"): pass flattened.append(item) total = len(flattened) page = max(1, page) page_size = min(max(1, page_size), 100) start = (page - 1) * page_size page_rows = flattened[start : start + page_size] return page_rows, total def create_collection_protocol( db: Session, *, protocol_no_id: Optional[str] = None, protocol_no: str = "", protocol_status: str = "dsx", protocol_attachments: Optional[str] = None, register_date: Optional[str] = None, collector_user_id: Optional[str] = None, remark: Optional[str] = None, tenant_no: str = "zkxw", user_id: Optional[str] = None, ) -> BizCollectionProtocol: pn = (protocol_no or "").strip() assert_biz_no_available(db, tenant_no=tenant_no, biz_no=pn) obj = BizCollectionProtocol( id=generate_uuid(), protocol_no_id=(protocol_no_id or "").strip() or None, protocol_no=pn, protocol_status=protocol_status.strip() or "dsx", protocol_attachments=(protocol_attachments or "").strip() or None, register_date=(register_date or "").strip() or None, collector_user_id=(collector_user_id or "").strip() or None, tenant_no=tenant_no, order_num=0, remark=(remark or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) db.add(obj) db.commit() db.refresh(obj) return obj def update_collection_protocol( db: Session, protocol_id: str, *, tenant_no: str, user_id: Optional[str] = None, scope_collector_user_id: Optional[str] = None, protocol_no: Optional[str] = None, protocol_status: Optional[str] = None, protocol_attachments: Optional[str] = None, register_date: Optional[str] = None, effective_time: Optional[datetime] = None, effective_desc: Optional[str] = None, expire_time: Optional[datetime] = None, expire_desc: Optional[str] = None, void_time: Optional[datetime] = None, void_desc: Optional[str] = None, to_research_time: Optional[datetime] = None, to_research_desc: Optional[str] = None, collector_user_id: Optional[str] = None, remark: Optional[str] = None, ) -> BizCollectionProtocol: obj = get_collection_protocol( db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id ) if not obj: raise ValueError("协议单不存在或无权操作") if protocol_no is not None: pn = protocol_no.strip() if not pn: raise ValueError("协议编号不能为空") if pn != obj.protocol_no: assert_biz_no_available( db, tenant_no=tenant_no, biz_no=pn, exclude_protocol_id=protocol_id, ) obj.protocol_no = pn if protocol_status is not None: obj.protocol_status = protocol_status.strip() if protocol_attachments is not None: obj.protocol_attachments = protocol_attachments.strip() or None if register_date is not None: obj.register_date = register_date.strip() or None if effective_time is not None: obj.effective_time = effective_time if effective_desc is not None: obj.effective_desc = effective_desc.strip() or None if expire_time is not None: obj.expire_time = expire_time if expire_desc is not None: obj.expire_desc = expire_desc.strip() or None if void_time is not None: obj.void_time = void_time if void_desc is not None: obj.void_desc = void_desc.strip() or None if to_research_time is not None: obj.to_research_time = to_research_time if to_research_desc is not None: obj.to_research_desc = to_research_desc.strip() or None if collector_user_id is not None: obj.collector_user_id = collector_user_id.strip() or None if remark is not None: obj.remark = remark.strip() or None obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj def soft_delete_collection_protocol( db: Session, protocol_id: str, tenant_no: str, user_id: Optional[str] = None, scope_collector_user_id: Optional[str] = None, ) -> None: obj = get_collection_protocol( db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id ) if not obj: raise ValueError("协议单不存在或无权操作") obj.del_flag = 1 obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() # --------------------------------------------------------------------------- # 子表:当事人客户 / 采集信息 / 样本 / 储存证书 # --------------------------------------------------------------------------- def get_party_customer(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolPartyCustomer]: return ( db.query(BizProtocolPartyCustomer) .filter( and_( BizProtocolPartyCustomer.collection_protocol_id == collection_protocol_id, BizProtocolPartyCustomer.tenant_no == tenant_no, BizProtocolPartyCustomer.del_flag == 0, ) ) .first() ) def upsert_party_customer( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], **fields, ) -> BizProtocolPartyCustomer: fields = _normalize_fields(fields) obj = get_party_customer(db, collection_protocol_id, tenant_no) _STR_FIELDS = [ "customer_id", "mother_name", "mother_phone", "mother_id_card", "mother_birth_ym", "mother_ethnic", "mother_address", "father_name", "father_phone", "father_id_card", "newborn_name", "newborn_id_card", "newborn_sex", "delivery_mode", "collector_user_id", ] _INT_FIELDS = ["birth_order", "newborn_weight_gram"] _DEC_FIELDS = ["apgar_1min", "apgar_5min"] _DT_FIELDS = ["newborn_delivery_time"] if obj: for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _INT_FIELDS: if k in fields: setattr(obj, k, fields[k]) for k in _DEC_FIELDS: if k in fields: setattr(obj, k, _optional_decimal(fields[k])) for k in _DT_FIELDS: if k in fields: setattr(obj, k, fields[k]) if "remark" in fields: obj.remark = (fields["remark"] or "").strip() or None obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj obj = BizProtocolPartyCustomer( id=generate_uuid(), collection_protocol_id=collection_protocol_id, tenant_no=tenant_no, order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _INT_FIELDS: if k in fields: setattr(obj, k, fields[k]) for k in _DEC_FIELDS: if k in fields: setattr(obj, k, _optional_decimal(fields[k])) for k in _DT_FIELDS: if k in fields: setattr(obj, k, fields[k]) db.add(obj) db.commit() db.refresh(obj) return obj def get_collection_info(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolCollectionInfo]: return ( db.query(BizProtocolCollectionInfo) .filter( and_( BizProtocolCollectionInfo.collection_protocol_id == collection_protocol_id, BizProtocolCollectionInfo.tenant_no == tenant_no, BizProtocolCollectionInfo.del_flag == 0, ) ) .first() ) def upsert_collection_info( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], **fields, ) -> BizProtocolCollectionInfo: fields = _normalize_fields(fields) obj = get_collection_info(db, collection_protocol_id, tenant_no) _STR_FIELDS = ["region_id", "hospital_id", "medical_staff", "sales_user_id"] _DT_FIELDS = ["sample_collect_time", "maternal_blood_collect_time"] if obj: for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _DT_FIELDS: if k in fields: setattr(obj, k, fields[k]) if "remark" in fields: obj.remark = (fields["remark"] or "").strip() or None obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj obj = BizProtocolCollectionInfo( id=generate_uuid(), collection_protocol_id=collection_protocol_id, tenant_no=tenant_no, order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _DT_FIELDS: if k in fields: setattr(obj, k, fields[k]) db.add(obj) db.commit() db.refresh(obj) return obj def list_samples(db: Session, collection_protocol_id: str, tenant_no: str) -> List[BizProtocolSample]: return ( db.query(BizProtocolSample) .filter( and_( BizProtocolSample.collection_protocol_id == collection_protocol_id, BizProtocolSample.tenant_no == tenant_no, BizProtocolSample.del_flag == 0, ) ) .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc()) .all() ) def save_samples( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], samples: list, ) -> List[BizProtocolSample]: existing = list_samples(db, collection_protocol_id, tenant_no) for s in existing: s.del_flag = 1 s.update_by = user_id s.update_time = datetime.now() db.add(s) if existing: db.flush() validate_sample_codes_not_used( db, tenant_no=tenant_no, samples=samples, exclude_protocol_id=collection_protocol_id, ) _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", ] _DEC_FIELDS = ["collect_amount", "price"] _INT_FIELDS = ["storage_years"] result = [] for idx, raw in enumerate(samples): raw = _normalize_fields(raw) obj = BizProtocolSample( id=generate_uuid(), collection_protocol_id=collection_protocol_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=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) for k in _STR_FIELDS: if k == "sample_status": continue if k in raw: setattr(obj, k, (raw[k] or "").strip() or None) # 样本状态:显式支持 null/空字符串,避免 ORM/库默认值 ycj 回填 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 _DEC_FIELDS: if k in raw and raw[k] is not None: setattr(obj, k, Decimal(str(raw[k]))) for k in _INT_FIELDS: if k in raw: setattr(obj, k, raw[k]) db.add(obj) result.append(obj) db.commit() for o in result: db.refresh(o) save_sample_transports(db, collection_protocol_id, tenant_no, user_id, result, samples) return result def list_sample_transports( db: Session, collection_protocol_id: str, tenant_no: str, ) -> List[BizProtocolSampleTransport]: return ( db.query(BizProtocolSampleTransport) .filter( and_( BizProtocolSampleTransport.collection_protocol_id == collection_protocol_id, BizProtocolSampleTransport.tenant_no == tenant_no, BizProtocolSampleTransport.del_flag == 0, ) ) .order_by(BizProtocolSampleTransport.order_num.asc(), BizProtocolSampleTransport.create_time.asc()) .all() ) def _soft_delete_transports_for_protocol( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], ) -> None: rows = list_sample_transports(db, collection_protocol_id, tenant_no) 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 save_sample_transports( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], saved_samples: List[BizProtocolSample], input_samples: list, ) -> None: _soft_delete_transports_for_protocol(db, collection_protocol_id, tenant_no, user_id) for idx, sample_obj in enumerate(saved_samples): if idx >= len(input_samples): break raw = _normalize_fields(input_samples[idx] if isinstance(input_samples[idx], dict) else {}) transport_raw = raw.get("transport") if not isinstance(transport_raw, dict): continue tnorm = _normalize_fields(transport_raw) transport_type = (tnorm.get("transport_type") or "").strip() if not transport_type: continue 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 = BizProtocolSampleTransport( id=generate_uuid(), collection_protocol_id=collection_protocol_id, sample_id=sample_obj.id, sample_code=sample_obj.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=idx, remark=(tnorm.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) db.add(obj) db.commit() def get_storage_cert(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolStorageCert]: return ( db.query(BizProtocolStorageCert) .filter( and_( BizProtocolStorageCert.collection_protocol_id == collection_protocol_id, BizProtocolStorageCert.tenant_no == tenant_no, BizProtocolStorageCert.del_flag == 0, ) ) .first() ) def _add_months_yyyymmdd(date_str: str, months: int) -> str: raw = (date_str or "").strip().replace("-", "") if len(raw) != 8: return "" y = int(raw[0:4]) m = int(raw[4:6]) d = int(raw[6:8]) month_index = m - 1 + months ny = y + month_index // 12 nm = month_index % 12 + 1 last_day = calendar.monthrange(ny, nm)[1] nd = min(d, last_day) return f"{ny:04d}{nm:02d}{nd:02d}" def _calc_installment_due_amounts( contract_amount: Optional[Decimal], count: int, ) -> List[Decimal]: if contract_amount is None or count < 1: return [] total_cents = int(round(float(contract_amount) * 100)) base_cents = total_cents // count amounts: List[Decimal] = [] sum_cents = 0 for i in range(count): if i == count - 1: cents = total_cents - sum_cents else: cents = base_cents sum_cents += base_cents amounts.append(Decimal(cents) / Decimal(100)) return amounts def align_fq_payment_details_payload( pay: BizProtocolPayment, details: Optional[list], ) -> list: if pay.pay_mode != "fq" or not pay.installment_count or int(pay.installment_count) < 1: return list(details or []) count = int(pay.installment_count) rows = [_normalize_fields(dict(d)) for d in (details or [])] if len(rows) >= count: needed = set(range(1, count + 1)) have = { int(r["installment_no"]) for r in rows if r.get("installment_no") is not None } if needed - have == set(): return rows[:count] by_no: Dict[int, Dict[str, Any]] = {} unnumbered: List[Dict[str, Any]] = [] for r in rows: no = r.get("installment_no") if no is not None: by_no[int(no)] = r else: unnumbered.append(r) amounts = _calc_installment_due_amounts(pay.contract_amount, count) start = (pay.installment_start_date or "").strip() interval = int(pay.installment_interval_months or 0) result: List[Dict[str, Any]] = [] for i in range(count): no = i + 1 if no in by_no: result.append(by_no[no]) continue if i < len(unnumbered): raw = dict(unnumbered[i]) raw["installment_no"] = no if not raw.get("installment_due_date") and start and interval: raw["installment_due_date"] = _add_months_yyyymmdd(start, i * interval) result.append(raw) continue due_date = _add_months_yyyymmdd(start, i * interval) if start and interval else "" due = amounts[i] if i < len(amounts) else None result.append( { "installment_no": no, "installment_due_date": due_date or None, "due_amount": float(due) if due is not None else None, "pay_status": "dfj", "transfer_type": "", "transfer_amount": None, "attachments": [], } ) return result def list_payment_details( db: Session, payment_id: str, tenant_no: str, ) -> List[BizProtocolPaymentDetail]: return ( db.query(BizProtocolPaymentDetail) .filter( and_( BizProtocolPaymentDetail.payment_id == payment_id, BizProtocolPaymentDetail.tenant_no == tenant_no, BizProtocolPaymentDetail.del_flag == 0, ) ) .order_by( BizProtocolPaymentDetail.installment_no.asc(), BizProtocolPaymentDetail.order_num.asc(), BizProtocolPaymentDetail.create_time.asc(), ) .all() ) def upsert_payment( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], **fields, ) -> BizProtocolPayment: fields = _normalize_fields(fields) obj = get_payment_by_protocol_id(db, collection_protocol_id, tenant_no) _STR_FIELDS = ["pay_mode"] _STR_DATE_FIELDS = ["lump_sum_deadline", "installment_start_date"] _DEC_FIELDS = ["contract_amount"] _INT_FIELDS = ["installment_count", "installment_interval_months"] def _apply(target: BizProtocolPayment) -> None: for k in _STR_FIELDS: if k in fields: setattr(target, k, (fields[k] or "").strip() or None) for k in _STR_DATE_FIELDS: if k in fields: setattr(target, k, _normalize_yyyymmdd_date(fields[k])) for k in _DEC_FIELDS: if k in fields and fields[k] is not None: setattr(target, k, Decimal(str(fields[k]))) for k in _INT_FIELDS: if k in fields: setattr(target, k, fields[k]) if "remark" in fields: target.remark = (fields["remark"] or "").strip() or None if obj: _apply(obj) obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj pay_mode = (fields.get("pay_mode") or "").strip() or "dj" obj = BizProtocolPayment( id=generate_uuid(), collection_protocol_id=collection_protocol_id, pay_mode=pay_mode, tenant_no=tenant_no, order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) _apply(obj) db.add(obj) db.commit() db.refresh(obj) return obj def save_payment_details( db: Session, payment_id: str, tenant_no: str, user_id: Optional[str], details: list, ) -> List[BizProtocolPaymentDetail]: existing = list_payment_details(db, payment_id, tenant_no) for d in existing: d.del_flag = 1 d.update_by = user_id d.update_time = datetime.now() db.add(d) if existing: db.flush() _STR_FIELDS = [ "transfer_type", "pay_status", "finance_remark", "refund_finance_remark", "refund_remark", ] _STR_DATE_FIELDS = ["installment_due_date"] _DEC_FIELDS = ["due_amount", "transfer_amount", "refund_amount"] _INT_FIELDS = ["installment_no"] result: List[BizProtocolPaymentDetail] = [] for idx, raw in enumerate(details or []): raw = _normalize_fields(dict(raw)) transfer_att = _pop_attachment_urls( raw, "attachments", "transfer_attachments", "transferAttachments" ) finance_confirm_att = _pop_attachment_urls( raw, "finance_confirm_attachments", "financeConfirmAttachments", "finance_confirm_attachment_files", "financeConfirmAttachmentFiles", ) refund_att = _pop_attachment_urls( raw, "refund_attachments", "refundAttachments", "refund_attachment_files", "refundAttachmentFiles", ) refund_finance_att = _pop_attachment_urls( raw, "refund_finance_confirm_attachments", "refundFinanceConfirmAttachments", "refund_finance_confirm_attachment_files", "refundFinanceConfirmAttachmentFiles", ) transfer_time = raw.get("transfer_time") if isinstance(transfer_time, str): transfer_time = parse_datetime(transfer_time) installment_no = raw.get("installment_no") if installment_no is None and raw.get("installment_due_date"): installment_no = idx + 1 obj = BizProtocolPaymentDetail( id=generate_uuid(), payment_id=payment_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=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, transfer_attachments=transfer_att, finance_confirm_attachments=finance_confirm_att, refund_attachments=refund_att, refund_finance_confirm_attachments=refund_finance_att, transfer_time=transfer_time, installment_no=installment_no, ) for k in _STR_FIELDS: if k in raw: setattr(obj, k, (raw[k] or "").strip() or None) for k in _STR_DATE_FIELDS: if k in raw: setattr(obj, k, _normalize_yyyymmdd_date(raw[k])) for k in _DEC_FIELDS: if k in raw and raw[k] is not None: setattr(obj, k, Decimal(str(raw[k]))) for k in _INT_FIELDS: if k in raw and raw[k] is not None: setattr(obj, k, raw[k]) if "pay_status" not in raw or not (raw.get("pay_status") or "").strip(): obj.pay_status = "dfj" db.add(obj) result.append(obj) db.commit() for o in result: db.refresh(o) return result def upsert_storage_cert( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], **fields, ) -> BizProtocolStorageCert: fields = _normalize_fields(fields) obj = get_storage_cert(db, collection_protocol_id, tenant_no) _STR_FIELDS = ["make_status", "cert_image_urls", "make_date"] if obj: for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) if "remark" in fields: obj.remark = (fields["remark"] or "").strip() or None obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj obj = BizProtocolStorageCert( id=generate_uuid(), collection_protocol_id=collection_protocol_id, tenant_no=tenant_no, order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) db.add(obj) db.commit() db.refresh(obj) return obj # --------------------------------------------------------------------------- # 子表:满月待办 # --------------------------------------------------------------------------- def _fullmoon_todo_to_dict(ft: BizProtocolFullmoonTodo) -> Dict[str, Any]: return { "id": ft.id, "collectionProtocolId": ft.collection_protocol_id, "todoStatus": ft.todo_status or "0", "handleDate": (ft.handle_date or "").strip() if ft.handle_date else "", "handleRemark": ft.handle_remark or "", "insuranceAttachments": ft.insurance_attachments or "", "insuranceCompanyId": ft.insurance_company_id or "", "insuranceCompanyName": ft.insurance_company_name or "", "insuranceType": ft.insurance_type or "", "insurancePolicyNo": ft.insurance_policy_no or "", "insuranceEffectiveDate": (ft.insurance_effective_date or "").strip() if ft.insurance_effective_date else "", "insuranceExpiryDate": (ft.insurance_expiry_date or "").strip() if ft.insurance_expiry_date else "", "insuranceSubscribed": ft.insurance_subscribed or "0", "insuranceFee": _dec(ft.insurance_fee), "tenantNo": ft.tenant_no, "orderNum": ft.order_num if ft.order_num is not None else 0, "remark": ft.remark or "", "createTime": _fmt_dt(ft.create_time), "updateTime": _fmt_dt(ft.update_time), } def get_fullmoon_todo( db: Session, collection_protocol_id: str, tenant_no: str, ) -> Optional[BizProtocolFullmoonTodo]: return ( db.query(BizProtocolFullmoonTodo) .filter( and_( BizProtocolFullmoonTodo.collection_protocol_id == collection_protocol_id, BizProtocolFullmoonTodo.tenant_no == tenant_no, BizProtocolFullmoonTodo.del_flag == 0, ) ) .first() ) def upsert_fullmoon_todo( db: Session, collection_protocol_id: str, tenant_no: str, user_id: Optional[str], **fields, ) -> BizProtocolFullmoonTodo: fields = _normalize_fields(fields) obj = get_fullmoon_todo(db, collection_protocol_id, tenant_no) _STR_FIELDS = [ "todo_status", "handle_date", "handle_remark", "insurance_attachments", "insurance_company_id", "insurance_company_name", "insurance_type", "insurance_policy_no", "insurance_effective_date", "insurance_expiry_date", "insurance_subscribed", ] _DEC_FIELDS = ["insurance_fee"] if obj: for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _DEC_FIELDS: if k in fields and fields[k] is not None: setattr(obj, k, Decimal(str(fields[k]))) if "remark" in fields: obj.remark = (fields["remark"] or "").strip() or None obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) db.commit() db.refresh(obj) return obj obj = BizProtocolFullmoonTodo( id=generate_uuid(), collection_protocol_id=collection_protocol_id, tenant_no=tenant_no, order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, create_dept_id="", create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) for k in _STR_FIELDS: if k in fields: setattr(obj, k, (fields[k] or "").strip() or None) for k in _DEC_FIELDS: if k in fields and fields[k] is not None: setattr(obj, k, Decimal(str(fields[k]))) db.add(obj) db.commit() db.refresh(obj) return obj