diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py index 316238ab..611fe4d4 100644 --- a/backend/app/api/collection_protocol.py +++ b/backend/app/api/collection_protocol.py @@ -184,12 +184,16 @@ class ProtocolCreateBody(BaseModel): health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls") register_date: Optional[str] = Field(None, alias="registerDate") collector_user_id: Optional[str] = Field(None, alias="collectorUserId") + protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount") + source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId") + payment_model_version: Optional[int] = Field(2, alias="paymentModelVersion") remark: Optional[str] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = Field(None, alias="samples") payment: Optional[dict] = Field(None, alias="payment") payment_details: Optional[list] = Field(None, alias="paymentDetails") + sample_payments: Optional[list] = Field(None, alias="samplePayments") attachments: Optional[list] = Field(None, alias="attachments") consent_attachments: Optional[list] = Field(None, alias="consentAttachments") health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments") @@ -215,12 +219,15 @@ class ProtocolUpdateBody(BaseModel): to_research_time: Optional[str] = Field(None, alias="toResearchTime") to_research_desc: Optional[str] = Field(None, alias="toResearchDesc") collector_user_id: Optional[str] = Field(None, alias="collectorUserId") + protocol_total_amount: Optional[float] = Field(None, alias="protocolTotalAmount") + source_presign_order_id: Optional[str] = Field(None, alias="sourcePresignOrderId") remark: Optional[str] = None party_customer: Optional[dict] = Field(None, alias="partyCustomer") collection_info: Optional[dict] = Field(None, alias="collectionInfo") samples: Optional[list] = Field(None, alias="samples") payment: Optional[dict] = Field(None, alias="payment") payment_details: Optional[list] = Field(None, alias="paymentDetails") + sample_payments: Optional[list] = Field(None, alias="samplePayments") attachments: Optional[list] = Field(None, alias="attachments") consent_attachments: Optional[list] = Field(None, alias="consentAttachments") health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments") @@ -268,12 +275,14 @@ def _persist_protocol_sub_resources( samples: Optional[list], payment: Optional[dict], payment_details: Optional[list], + sample_payments: Optional[list] = None, storage_cert: Optional[dict], + payment_model_version: int = 2, ) -> None: """持久化协议子资源。 - 缴费(含明细校验)须在样本之前:明细校验失败时不得已占用样本编码。 - 各子表 CRUD 内部仍可能分段 commit,创建接口须在失败时做补偿软删。 + version=1:缴费(含明细校验)须在样本之前,避免校验失败后已占用样本编码。 + version=2:样本先落库占码,再按 sample_code 挂样本缴费方案。 """ if party_customer: _parse_datetime_fields(party_customer, ["newbornDeliveryTime"]) @@ -282,20 +291,37 @@ def _persist_protocol_sub_resources( if collection_info: _parse_datetime_fields(collection_info, ["sampleCollectTime", "maternalBloodCollectTime"]) cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info) - # 缴费先于样本:避免「应缴合计不符」等校验失败后样本编码已被占用 - pay = None - if payment: - pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment) - if payment_details is not None: - if not pay and payment: + + version = int(payment_model_version or 2) + if version >= 2: + if samples is not None: + cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples) + if sample_payments is not None: + from app.crud import protocol_sample_payment as sp_pay_svc + + sp_pay_svc.save_sample_payments_for_protocol( + db, + collection_protocol_id=protocol_id, + tenant_no=tenant_no, + user_id=user_id, + sample_payments=sample_payments, + ) + else: + # 旧模型:缴费先于样本 + pay = None + if payment: pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment) - if not pay: - pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no) - if pay: - aligned_details = cp_svc.align_fq_payment_details_payload(pay, payment_details) - cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, aligned_details) - if samples is not None: - cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples) + if payment_details is not None: + if not pay and payment: + pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment) + if not pay: + pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no) + if pay: + aligned_details = cp_svc.align_fq_payment_details_payload(pay, payment_details) + cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, aligned_details) + if samples is not None: + cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples) + if storage_cert: cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert) @@ -335,6 +361,11 @@ async def create_collection_protocol( health_survey_form_urls=health_survey_form_urls, register_date=body.register_date, collector_user_id=collector, + protocol_total_amount=body.protocol_total_amount, + source_presign_order_id=body.source_presign_order_id, + payment_model_version=body.payment_model_version + if body.payment_model_version is not None + else 2, remark=body.remark, tenant_no=tenant_no, user_id=user_id, @@ -350,7 +381,11 @@ async def create_collection_protocol( samples=body.samples, payment=body.payment, payment_details=body.payment_details, + sample_payments=body.sample_payments, storage_cert=body.storage_cert, + payment_model_version=int( + getattr(obj, "payment_model_version", None) or 2 + ), ) return success_response(cp_svc._protocol_to_dict(obj), message="创建成功") except ValueError as e: @@ -383,7 +418,7 @@ async def update_collection_protocol( payload = body.model_dump(exclude_unset=True, by_alias=False) sub_keys = { "party_customer", "collection_info", "samples", - "payment", "payment_details", "attachments", + "payment", "payment_details", "sample_payments", "attachments", "consent_attachments", "health_survey_attachments", "storage_cert", } main_payload = {k: v for k, v in payload.items() if k not in sub_keys} @@ -413,7 +448,11 @@ async def update_collection_protocol( protocol_id, tenant_no=tenant_no, user_id=user_id, - **{k: v for k, v in main_payload.items() if v is not None}, + **{ + k: v + for k, v in main_payload.items() + if v is not None or k == "protocol_total_amount" + }, ) _persist_protocol_sub_resources( db, @@ -425,7 +464,11 @@ async def update_collection_protocol( samples=body.samples, payment=body.payment, payment_details=body.payment_details, + sample_payments=body.sample_payments, storage_cert=body.storage_cert, + payment_model_version=int( + getattr(obj, "payment_model_version", None) or 1 + ), ) return success_response(cp_svc._protocol_to_dict(obj), message="更新成功") except ValueError as e: diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index c96281cd..582ed5c2 100644 --- a/backend/app/crud/collection_protocol.py +++ b/backend/app/crud/collection_protocol.py @@ -400,6 +400,13 @@ def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]: "toResearchTime": _fmt_dt(p.to_research_time), "toResearchDesc": p.to_research_desc or "", "collectorUserId": p.collector_user_id or "", + "protocolTotalAmount": _dec(p.protocol_total_amount) + if getattr(p, "protocol_total_amount", None) is not None + else None, + "sourcePresignOrderId": getattr(p, "source_presign_order_id", None) or "", + "paymentModelVersion": int( + getattr(p, "payment_model_version", None) or 1 + ), "tenantNo": p.tenant_no, "orderNum": p.order_num if p.order_num is not None else 0, "remark": p.remark or "", @@ -1387,6 +1394,25 @@ def get_protocol_detail_bundle( out["paymentDetails"] = details_out else: out["paymentDetails"] = [] + + from app.crud import protocol_sample_payment as sp_pay_svc + from app.crud import protocol_refund_record as rr_svc + + version = int(getattr(p, "payment_model_version", None) or 1) + if version >= 2: + out["samplePayments"] = sp_pay_svc.get_sample_payments_bundle( + db, protocol_id, tenant_no + ) + else: + out["samplePayments"] = [] + out["refundRecords"] = rr_svc.list_refund_records( + db, collection_protocol_id=protocol_id, tenant_no=tenant_no + ) + out["presignDepositHint"] = sp_pay_svc.get_presign_deposit_hint( + db, + source_presign_order_id=getattr(p, "source_presign_order_id", None), + tenant_no=tenant_no, + ) return out @@ -2304,6 +2330,9 @@ def create_collection_protocol( health_survey_form_urls: Optional[str] = None, register_date: Optional[str] = None, collector_user_id: Optional[str] = None, + protocol_total_amount: Any = None, + source_presign_order_id: Optional[str] = None, + payment_model_version: Optional[int] = None, remark: Optional[str] = None, tenant_no: str = "zkxw", user_id: Optional[str] = None, @@ -2314,6 +2343,12 @@ def create_collection_protocol( db, tenant_no=tenant_no, kind="collection", user_id=user_id ) assert_biz_no_available(db, tenant_no=tenant_no, biz_no=pn) + version = int(payment_model_version) if payment_model_version is not None else 2 + if version not in (1, 2): + version = 2 + total_amt = None + if protocol_total_amount not in (None, ""): + total_amt = Decimal(str(protocol_total_amount)) obj = BizCollectionProtocol( id=generate_uuid(), protocol_no_id=(protocol_no_id or "").strip() or None, @@ -2324,6 +2359,9 @@ def create_collection_protocol( health_survey_form_urls=(health_survey_form_urls or "").strip() or None, register_date=(register_date or "").strip() or None, collector_user_id=(collector_user_id or "").strip() or None, + protocol_total_amount=total_amt, + source_presign_order_id=(source_presign_order_id or "").strip() or None, + payment_model_version=version, tenant_no=tenant_no, order_num=0, remark=(remark or "").strip() or None, @@ -2363,6 +2401,8 @@ def update_collection_protocol( to_research_time: Optional[datetime] = None, to_research_desc: Optional[str] = None, collector_user_id: Optional[str] = None, + protocol_total_amount: Any = None, + source_presign_order_id: Optional[str] = None, remark: Optional[str] = None, ) -> BizCollectionProtocol: obj = get_collection_protocol( @@ -2413,6 +2453,13 @@ def update_collection_protocol( 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 protocol_total_amount is not None: + if protocol_total_amount == "": + obj.protocol_total_amount = None + else: + obj.protocol_total_amount = Decimal(str(protocol_total_amount)) + if source_presign_order_id is not None: + obj.source_presign_order_id = (source_presign_order_id or "").strip() or None if remark is not None: obj.remark = remark.strip() or None obj.update_by = user_id diff --git a/backend/app/crud/protocol_sample_payment.py b/backend/app/crud/protocol_sample_payment.py index e9ae468d..3f2def2d 100644 --- a/backend/app/crud/protocol_sample_payment.py +++ b/backend/app/crud/protocol_sample_payment.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional, Tuple @@ -13,6 +14,7 @@ from app.core.utils import generate_uuid, parse_datetime from app.crud.collection_protocol import urls_to_attachment_list from app.models.models import ( BizCollectionProtocol, + BizPresignTransfer, BizProtocolPartyCustomer, BizProtocolPaymentSchedule, BizProtocolPaymentTransfer, @@ -569,6 +571,192 @@ def _sample_payment_to_dict( } +def _normalize_installment_ratios(v: Any) -> Optional[str]: + if v is None or v == "": + return None + if isinstance(v, (list, dict)): + return json.dumps(v, ensure_ascii=False) + return str(v).strip() or None + + +def _soft_delete_sample_payment( + db: Session, + *, + sp: BizProtocolSamplePayment, + tenant_no: str, + user_id: Optional[str], +) -> None: + assert_sample_payment_editable(db, sample_payment_id=sp.id, tenant_no=tenant_no) + now = _now() + schedules = ( + db.query(BizProtocolPaymentSchedule) + .filter( + BizProtocolPaymentSchedule.sample_payment_id == sp.id, + BizProtocolPaymentSchedule.tenant_no == tenant_no, + BizProtocolPaymentSchedule.del_flag == 0, + ) + .all() + ) + sids = [s.id for s in schedules] + if sids: + transfers = ( + db.query(BizProtocolPaymentTransfer) + .filter( + BizProtocolPaymentTransfer.schedule_id.in_(sids), + BizProtocolPaymentTransfer.tenant_no == tenant_no, + BizProtocolPaymentTransfer.del_flag == 0, + ) + .all() + ) + for t in transfers: + if t.pay_status == "jfqrz": + raise ValueError("存在缴费确认中的转账笔,无法删除样本缴费方案") + if t.pay_status == "dfj": + t.del_flag = 1 + t.update_by = user_id + t.update_time = now + db.add(t) + for s in schedules: + s.del_flag = 1 + s.update_by = user_id + s.update_time = now + db.add(s) + sp.del_flag = 1 + sp.update_by = user_id + sp.update_time = now + db.add(sp) + + +def save_sample_payments_for_protocol( + db: Session, + *, + collection_protocol_id: str, + tenant_no: str, + user_id: Optional[str], + sample_payments: List[Dict[str, Any]], +) -> None: + """按样本保存缴费方案:upsert 方案 → 重建批次 → 写入待缴费转账笔。""" + existing = ( + db.query(BizProtocolSamplePayment) + .filter( + BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id, + BizProtocolSamplePayment.tenant_no == tenant_no, + BizProtocolSamplePayment.del_flag == 0, + ) + .all() + ) + keep_ids: set = set() + for raw in sample_payments or []: + if not isinstance(raw, dict): + continue + sample_code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip() + if not sample_code: + raise ValueError("样本缴费方案缺少样本编码") + pay_mode = (raw.get("pay_mode") or raw.get("payMode") or "").strip() + sp_id = (raw.get("id") or raw.get("samplePaymentId") or "").strip() or None + ratios = raw.get("installment_ratios") + if ratios is None: + ratios = raw.get("installmentRatios") + sp = upsert_sample_payment( + db, + collection_protocol_id=collection_protocol_id, + tenant_no=tenant_no, + user_id=user_id, + sample_code=sample_code, + pay_mode=pay_mode, + contract_amount=raw.get("contract_amount", raw.get("contractAmount")), + protocol_sample_id=raw.get("protocol_sample_id") or raw.get("protocolSampleId"), + installment_count=raw.get("installment_count", raw.get("installmentCount")), + installment_interval_months=raw.get( + "installment_interval_months", raw.get("installmentIntervalMonths") + ), + installment_start_date=raw.get( + "installment_start_date", raw.get("installmentStartDate") + ), + installment_advance_days=raw.get( + "installment_advance_days", raw.get("installmentAdvanceDays") + ), + installment_ratios=_normalize_installment_ratios(ratios), + lump_sum_deadline=raw.get("lump_sum_deadline", raw.get("lumpSumDeadline")), + remark=raw.get("remark"), + sample_payment_id=sp_id, + ) + keep_ids.add(sp.id) + schedules_payload = raw.get("schedules") or [] + created = replace_schedules( + db, + sample_payment_id=sp.id, + tenant_no=tenant_no, + user_id=user_id, + schedules=schedules_payload, + ) + for i, sch_raw in enumerate(schedules_payload): + if i >= len(created) or not isinstance(sch_raw, dict): + continue + for t_raw in sch_raw.get("transfers") or []: + if not isinstance(t_raw, dict): + continue + status = ( + t_raw.get("pay_status") or t_raw.get("payStatus") or "dfj" + ).strip() + # 仅重建待缴费草稿;jfqrz/yjf 由既有流程保留在旧批次或独立接口维护 + if status != "dfj": + continue + add_transfer( + db, + schedule_id=created[i].id, + tenant_no=tenant_no, + user_id=user_id, + transfer_amount=t_raw.get( + "transfer_amount", t_raw.get("transferAmount") + ), + transfer_time=t_raw.get( + "transfer_time", t_raw.get("transferTime") + ), + transfer_type=t_raw.get( + "transfer_type", t_raw.get("transferType") + ), + transfer_attachments=t_raw.get( + "transfer_attachments", t_raw.get("transferAttachments") + ), + remark=t_raw.get("remark"), + commit=False, + ) + for sp in existing: + if sp.id not in keep_ids: + _soft_delete_sample_payment( + db, sp=sp, tenant_no=tenant_no, user_id=user_id + ) + db.commit() + + +def get_presign_deposit_hint( + db: Session, *, source_presign_order_id: Optional[str], tenant_no: str +) -> Optional[Dict[str, Any]]: + pid = (source_presign_order_id or "").strip() + if not pid: + return None + rows = ( + db.query(BizPresignTransfer) + .filter( + BizPresignTransfer.presign_order_id == pid, + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + BizPresignTransfer.pay_status == "yjf", + ) + .all() + ) + total = Decimal("0") + for r in rows: + if r.transfer_amount is not None: + total += Decimal(str(r.transfer_amount)) + return { + "presignOrderId": pid, + "confirmedDepositAmount": float(total), + "confirmedTransferCount": len(rows), + } + + def get_sample_payments_bundle( db: Session, collection_protocol_id: str, tenant_no: str ) -> List[Dict[str, Any]]: