浏览代码

fix(backend): align pay-status user messages with 付款 wording

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 周之前
父节点
当前提交
727002761d

+ 1 - 1
backend/app/crud/clinical_payment.py

@@ -405,7 +405,7 @@ def submit_refund(
 ) -> BizPilotPayment:
     row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no)
     if row.pay_status != "yjf":
-        raise ValueError("仅已缴费状态可发起退费")
+        raise ValueError("仅付款完成状态可发起退费")
     if refund_amount is None or Decimal(str(refund_amount)) <= 0:
         raise ValueError("请填写有效的退费金额")
     row.pay_status = "tfqrz"

+ 45 - 17
backend/app/crud/collection_protocol.py

@@ -1191,7 +1191,9 @@ def list_collection_protocols(
                 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():
+            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 = {
@@ -2021,7 +2023,7 @@ def _apply_payment_detail_draft_fields(
     from app.crud.dict_data import normalize_transfer_type
 
     if d.pay_status != "dfj":
-        raise ValueError("仅已缴费状态可编辑缴费明细")
+        raise ValueError("仅已付款状态可编辑缴费明细")
     if "transfer_type" in fields:
         d.transfer_type = normalize_transfer_type(
             db, value=str(fields.get("transfer_type") or ""), tenant_no=tenant_no
@@ -2076,7 +2078,7 @@ def _apply_submit_receipt_finance(
     user_id: Optional[str],
 ) -> None:
     if d.pay_status != "dfj":
-        raise ValueError("仅已缴费状态可提交财务确认")
+        raise ValueError("仅已付款状态可提交财务确认")
     d.pay_status = "jfqrz"
     d.update_by = user_id
     d.update_time = datetime.now()
@@ -2130,7 +2132,7 @@ def _apply_submit_refund_finance(
     refund_attachments: Optional[str] = None,
 ) -> None:
     if d.pay_status != "yjf":
-        raise ValueError("仅已缴费状态可提交退费财务确认")
+        raise ValueError("仅已付款状态可提交退费财务确认")
     if refund_amount is None or Decimal(str(refund_amount)) <= 0:
         raise ValueError("请填写有效的退费金额")
     d.pay_status = "tfqrz"
@@ -2275,7 +2277,7 @@ def list_payment_details_page(
     page = max(1, page)
     page_size = min(max(1, page_size), 100)
     due_col = BizProtocolPaymentDetail.installment_due_date
-    # 缴费信息 Tab:待缴费/缴费确认中/退费确认中 → 应缴日期正序;已缴费/已退费 → 倒序
+    # 缴费信息 Tab:已付款/付款确认中/退费确认中 → 应缴日期正序;付款完成/已退费 → 倒序
     due_order = due_col.desc() if pay_status in ("yjf", "ytf") else due_col.asc()
     rows = (
         q.order_by(due_order)
@@ -2392,13 +2394,20 @@ def create_collection_protocol(
     user_id: Optional[str] = None,
     create_dept_id: str = "",
     commit: bool = True,
+    exclude_presign_order_id: Optional[str] = None,
 ) -> BizCollectionProtocol:
     pn = (protocol_no or "").strip()
     if not pn:
         pn = allocate_next_biz_no(
             db, tenant_no=tenant_no, kind="collection", user_id=user_id
         )
-    assert_biz_no_available(db, tenant_no=tenant_no, biz_no=pn)
+    # 预签转正式时排除来源预签订单自身占用的编号
+    assert_biz_no_available(
+        db,
+        tenant_no=tenant_no,
+        biz_no=pn,
+        exclude_presign_order_id=exclude_presign_order_id or source_presign_order_id,
+    )
     version = int(payment_model_version) if payment_model_version is not None else 2
     if version not in (1, 2):
         version = 2
@@ -2879,13 +2888,10 @@ def save_samples(
         new_code_to_id=new_code_to_id,
         user_id=user_id,
     )
-    if not commit:
-        db.flush()
-        return result
-    db.commit()
+    db.flush()
     for o in result:
         db.refresh(o)
-    # 本阶段停写旧运输明细,改挂运费批次
+    # 运费批次 + 预计到达(旧运输明细仅保留 eta 等仍在用的字段)
     from app.crud import sample_freight as sample_freight_svc
 
     sample_freight_svc.apply_freight_on_protocol_save(
@@ -2897,7 +2903,21 @@ def save_samples(
         saved_samples=result,
         input_samples=samples,
     )
-    db.commit()
+    save_sample_transports(
+        db,
+        collection_protocol_id=collection_protocol_id,
+        tenant_no=tenant_no,
+        user_id=user_id,
+        saved_samples=result,
+        input_samples=samples,
+        commit=False,
+    )
+    if commit:
+        db.commit()
+        for o in result:
+            db.refresh(o)
+    else:
+        db.flush()
     return result
 
 
@@ -2943,7 +2963,9 @@ def save_sample_transports(
     user_id: Optional[str],
     saved_samples: List[BizProtocolSample],
     input_samples: list,
+    commit: bool = True,
 ) -> None:
+    """写回样本运输明细。运费批次改造后仍保留预计到达等字段(可仅有 eta_time)。"""
     _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):
@@ -2951,15 +2973,18 @@ def save_sample_transports(
         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
+            transport_raw = {}
         tnorm = _normalize_fields(transport_raw)
-        transport_type = (tnorm.get("transport_type") or "").strip()
-        if not transport_type:
-            continue
+        # 移动端可能把 etaTime 放在样本顶层
+        if not tnorm.get("eta_time") and raw.get("eta_time"):
+            tnorm["eta_time"] = raw.get("eta_time")
 
+        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:
+            continue
 
         express_fee = tnorm.get("express_fee")
         if express_fee is not None and express_fee != "":
@@ -2991,7 +3016,10 @@ def save_sample_transports(
             del_flag=0,
         )
         db.add(obj)
-    db.commit()
+    if commit:
+        db.commit()
+    else:
+        db.flush()
 
 
 def get_storage_cert(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolStorageCert]:

+ 19 - 14
backend/app/crud/presign_order.py

@@ -11,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid, parse_datetime
+from app.crud.biz_no_generator import allocate_next_biz_no
 from app.crud.collection_protocol import urls_to_attachment_list
 from app.models.models import (
     BizCellSampleType,
@@ -45,18 +46,16 @@ def _user_nick_name_map(
     if not ids:
         return {}
     rows = (
-        db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
+        db.query(SysUser.id, SysUser.nick_name)
         .filter(
             SysUser.id.in_(ids),
             SysUser.tenant_no == tenant_no,
-            SysUser.del_flag == 0,
-            SysUser.status == "1",
         )
         .all()
     )
     out: Dict[str, str] = {}
     for u in rows:
-        out[str(u.id)] = (u.nick_name or u.user_no or "").strip()
+        out[str(u.id)] = (u.nick_name or "").strip()
     return out
 
 
@@ -522,12 +521,15 @@ def create_presign_order(
     pay_mode = (fields.get("pay_mode") or fields.get("payMode") or "").strip() or None
     if pay_mode and pay_mode not in ("dj", "fq"):
         raise ValueError("缴费方式无效")
+    # 协议编号由服务端发号,忽略客户端传入值
+    protocol_no = allocate_next_biz_no(
+        db, tenant_no=tenant_no, kind="collection", user_id=user_id
+    )
     obj = BizPresignOrder(
         id=generate_uuid(),
         order_date=(fields.get("order_date") or fields.get("orderDate") or "").strip()
         or None,
-        protocol_no=(fields.get("protocol_no") or fields.get("protocolNo") or "").strip()
-        or None,
+        protocol_no=protocol_no,
         mother_name=(fields.get("mother_name") or fields.get("motherName") or "").strip()
         or None,
         expected_delivery_date=(
@@ -614,8 +616,6 @@ def update_presign_order(
     mapping = {
         "order_date": "order_date",
         "orderDate": "order_date",
-        "protocol_no": "protocol_no",
-        "protocolNo": "protocol_no",
         "mother_name": "mother_name",
         "motherName": "mother_name",
         "expected_delivery_date": "expected_delivery_date",
@@ -641,7 +641,6 @@ def update_presign_order(
             val = fields[k]
             if attr in (
                 "order_date",
-                "protocol_no",
                 "mother_name",
                 "expected_delivery_date",
                 "region_id",
@@ -668,6 +667,11 @@ def update_presign_order(
             obj.sample_collect_time = parse_datetime(str(v))
     if obj.pay_mode and obj.pay_mode not in ("dj", "fq"):
         raise ValueError("缴费方式无效")
+    # 协议编号不可改;历史空号在编辑保存时补发
+    if not (obj.protocol_no or "").strip():
+        obj.protocol_no = allocate_next_biz_no(
+            db, tenant_no=tenant_no, kind="collection", user_id=user_id
+        )
     obj.update_by = user_id
     obj.update_time = _now()
     db.add(obj)
@@ -764,7 +768,7 @@ def update_transfer_draft(
 ) -> BizPresignTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if t.pay_status != "dfj":
-        raise ValueError("仅已缴费状态可编辑意向金转账笔")
+        raise ValueError("仅已付款状态可编辑意向金转账笔")
     values: Dict[Any, Any] = {
         BizPresignTransfer.update_by: user_id,
         BizPresignTransfer.update_time: _now(),
@@ -819,7 +823,7 @@ def submit_receipt_finance(
 ) -> BizPresignTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if not can_submit_receipt(t.pay_status):
-        raise ValueError("仅已缴费状态可提交财务确认")
+        raise ValueError("仅已付款状态可提交财务确认")
     if not (t.transfer_type or "").strip():
         raise ValueError("请选择转账方式")
     if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
@@ -854,7 +858,7 @@ def withdraw_receipt(
 ) -> BizPresignTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if not can_withdraw_receipt(t.pay_status):
-        raise ValueError("仅缴费确认中可撤回")
+        raise ValueError("仅付款确认中可撤回")
     # 故意不改 finance_remark / finance_confirm_attachments
     return _update_transfer_expected_status(
         db,
@@ -882,7 +886,7 @@ def confirm_receipt(
 ) -> BizPresignTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if not can_confirm_receipt(t.pay_status):
-        raise ValueError("仅缴费确认中状态可确认收款")
+        raise ValueError("仅付款确认中状态可确认收款")
     values: Dict[Any, Any] = {
         BizPresignTransfer.pay_status: "yjf",
         BizPresignTransfer.update_by: user_id,
@@ -916,7 +920,7 @@ def reject_receipt(
 ) -> BizPresignTransfer:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if not can_confirm_receipt(t.pay_status):
-        raise ValueError("仅缴费确认中状态可退回")
+        raise ValueError("仅付款确认中状态可退回")
     return _update_transfer_expected_status(
         db,
         transfer_id=transfer_id,
@@ -1076,6 +1080,7 @@ def convert_to_protocol(
             user_id=user_id,
             create_dept_id=create_dept_id,
             commit=False,
+            exclude_presign_order_id=order.id,
         )
         party_fields: Dict[str, Any] = {
             "mother_name": order.mother_name,

+ 5 - 5
backend/app/crud/protocol_sample_payment.py

@@ -180,7 +180,7 @@ def has_blocking_transfer_for_sample_payment(
 def assert_sample_payment_structure_editable(
     db: Session, *, sample_payment_id: str, tenant_no: str
 ) -> None:
-    """已废弃:缴费确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
+    """已废弃:付款确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
     return
 
 
@@ -802,7 +802,7 @@ def submit_receipt_finance(
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if draft_fields:
         if t.pay_status != "dfj":
-            raise ValueError("仅已缴费状态可编辑转账笔")
+            raise ValueError("仅已付款状态可编辑转账笔")
         # 就地改草稿字段(不单独 commit)
         if "transfer_type" in draft_fields or "transferType" in draft_fields:
             v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
@@ -825,7 +825,7 @@ def submit_receipt_finance(
             )
             t.transfer_attachments = (str(v) if v is not None else "").strip() or None
     if not can_submit_receipt(t.pay_status):
-        raise ValueError("仅已缴费状态可提交财务确认")
+        raise ValueError("仅已付款状态可提交财务确认")
     _validate_transfer_submit_ready(t)
     t.pay_status = "jfqrz"
     t.update_by = user_id
@@ -892,7 +892,7 @@ def withdraw_receipt(
     """业务撤回:jfqrz → dfj,保留 finance_remark / finance_confirm_attachments。"""
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if not can_withdraw_receipt(t.pay_status):
-        raise ValueError("仅缴费确认中可撤回")
+        raise ValueError("仅付款确认中可撤回")
     t.pay_status = "dfj"
     t.update_by = user_id
     t.update_time = _now()
@@ -911,7 +911,7 @@ def soft_delete_payment_transfer(
 ) -> None:
     t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
     if t.pay_status != "dfj":
-        raise ValueError("仅待缴费(已缴费草稿)转账笔可删除")
+        raise ValueError("仅已付款状态转账笔可删除")
     t.del_flag = 1
     t.update_by = user_id
     t.update_time = _now()

+ 129 - 8
backend/app/services/market_payment.py

@@ -12,12 +12,15 @@ from sqlalchemy.orm import Session, aliased
 from app.core.utils import parse_datetime
 from app.crud import protocol_sample_payment as payment_crud
 from app.models.models import (
+    BizCellSampleType,
     BizCollectionProtocol,
     BizProtocolCollectionInfo,
     BizProtocolPartyCustomer,
     BizProtocolPaymentSchedule,
     BizProtocolPaymentTransfer,
+    BizProtocolSample,
     BizProtocolSamplePayment,
+    SysUser,
 )
 from app.services.market_field_scope import (
     MarketFieldContext,
@@ -54,6 +57,61 @@ def _money(value: Any) -> Optional[str]:
     return format(Decimal(str(value)).quantize(Decimal('0.01')), 'f')
 
 
+def _display_ymd(value: Any) -> str:
+    text = str(value or '').strip()
+    if len(text) == 8 and text.isdigit():
+        return f'{text[:4]}-{text[4:6]}-{text[6:8]}'
+    return text
+
+
+def _load_sample_meta(
+    db: Session,
+    payments: List[BizProtocolSamplePayment],
+    tenant_no: str,
+) -> Dict[str, Dict[str, Any]]:
+    sample_codes = list({str(row.sample_code or '').strip() for row in payments if row.sample_code})
+    if not sample_codes:
+        return {}
+    rows = (
+        db.query(BizProtocolSample, BizCellSampleType)
+        .outerjoin(
+            BizCellSampleType,
+            and_(
+                BizCellSampleType.id == BizProtocolSample.sample_type_id,
+                BizCellSampleType.tenant_no == tenant_no,
+                BizCellSampleType.del_flag == 0,
+            ),
+        )
+        .filter(
+            BizProtocolSample.sample_code.in_(sample_codes),
+            BizProtocolSample.tenant_no == tenant_no,
+            BizProtocolSample.del_flag == 0,
+        )
+        .order_by(
+            BizProtocolSample.update_time.desc(),
+            BizProtocolSample.create_time.desc(),
+            BizProtocolSample.id.desc(),
+        )
+        .all()
+    )
+    result: Dict[str, Dict[str, Any]] = {}
+    for sample, sample_type in rows:
+        original = sample.original_price
+        current = sample.price
+        discount = None
+        if original is not None and current is not None and original > current:
+            discount = original - current
+        result.setdefault(
+            sample.sample_code or '',
+            {
+                'storageYears': sample.storage_years,
+                'sampleTypeName': sample_type.type_name if sample_type else '',
+                'discountAmount': _money(discount),
+            },
+        )
+    return result
+
+
 def _transfer_dict(row: BizProtocolPaymentTransfer) -> Dict[str, Any]:
     return {
         'id': row.id,
@@ -101,7 +159,43 @@ def _payment_dict(
     party: Optional[BizProtocolPartyCustomer],
     schedules: List[BizProtocolPaymentSchedule],
     transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
+    sample_meta: Optional[Dict[str, Any]] = None,
 ) -> Dict[str, Any]:
+    meta = sample_meta or {}
+    schedule_rows = [
+        _period_dict(row, transfers_by_schedule.get(row.id, []))
+        for row in schedules
+    ]
+    due_total = sum(
+        (Decimal(row['dueAmount'] or '0') for row in schedule_rows),
+        Decimal('0'),
+    )
+    paid_total = sum(
+        (Decimal(row['paidAmount'] or '0') for row in schedule_rows),
+        Decimal('0'),
+    )
+    remain_total = max(due_total - paid_total, Decimal('0'))
+    receivable_total = sum(
+        (
+            Decimal(str(row.due_amount or '0'))
+            for row in schedules
+            if (row.schedule_status or '') != 'void'
+        ),
+        Decimal('0'),
+    )
+    transfers = [
+        transfer
+        for schedule in schedules
+        for transfer in transfers_by_schedule.get(schedule.id, [])
+    ]
+    cleared_count = sum(
+        1 for row in schedule_rows if Decimal(row['arrearsAmount'] or '0') <= 0
+    )
+    progress = (
+        '已缴清' if schedule_rows and remain_total <= 0
+        else '部分缴费' if paid_total > 0
+        else '待缴费'
+    )
     return {
         'id': payment.id,
         'collectionProtocolId': protocol.id,
@@ -113,11 +207,20 @@ def _payment_dict(
         'sampleCode': payment.sample_code or '',
         'payMode': payment.pay_mode or '',
         'contractAmount': _money(payment.contract_amount),
+        'signDate': _display_ymd(getattr(protocol, 'register_date', None)),
+        'storageYears': meta.get('storageYears'),
+        'sampleTypeName': meta.get('sampleTypeName') or '',
+        'discountAmount': meta.get('discountAmount'),
+        'receivableAmount': _money(receivable_total),
         'remark': payment.remark or '',
-        'schedules': [
-            _period_dict(row, transfers_by_schedule.get(row.id, []))
-            for row in schedules
-        ],
+        'paidAmount': _money(paid_total),
+        'remainAmount': _money(remain_total),
+        'dueRemainAmount': _money(remain_total),
+        'clearedScheduleCount': cleared_count,
+        'activeScheduleCount': len(schedule_rows),
+        'paymentProgress': progress,
+        'periods': payment_crud._build_periods_array(schedules, transfers),
+        'schedules': schedule_rows,
     }
 
 
@@ -253,6 +356,7 @@ def list_payments(
     schedules, transfers = _load_children(
         db, [row[0].id for row in rows], ctx.tenant_no
     )
+    sample_meta = _load_sample_meta(db, [row[0] for row in rows], ctx.tenant_no)
     return [
         _payment_dict(
             payment,
@@ -260,6 +364,7 @@ def list_payments(
             party,
             schedules.get(payment.id, []),
             transfers,
+            sample_meta.get(payment.sample_code or ''),
         )
         for payment, protocol, party in rows
     ], int(total)
@@ -286,12 +391,14 @@ def get_payment_detail(
             detail='旧版协议缴费模型不支持移动端办理,请前往管理端查看',
         )
     schedules, transfers = _load_children(db, [payment.id], ctx.tenant_no)
+    sample_meta = _load_sample_meta(db, [payment], ctx.tenant_no)
     return _payment_dict(
         payment,
         protocol,
         party,
         schedules.get(payment.id, []),
         transfers,
+        sample_meta.get(payment.sample_code or ''),
     )
 
 
@@ -347,9 +454,7 @@ def list_payment_chase(
             func.sum(
                 case(
                     (
-                        BizProtocolPaymentTransfer.pay_status.in_(
-                            ('dfj', 'jfqrz', 'yjf')
-                        ),
+                        BizProtocolPaymentTransfer.pay_status == 'yjf',
                         BizProtocolPaymentTransfer.transfer_amount,
                     ),
                     else_=Decimal('0'),
@@ -473,6 +578,18 @@ def list_payment_chase(
         .limit(page_size)
         .all()
     )
+    sales_user_ids = {
+        collection.sales_user_id
+        for _, _, _, _, collection, _ in candidates
+        if collection.sales_user_id
+    }
+    sales_user_names = {
+        row.id: row.nick_name or ''
+        for row in db.query(SysUser).filter(
+            SysUser.id.in_(sales_user_ids or ['']),
+            SysUser.tenant_no == ctx.tenant_no,
+        )
+    }
     rows: List[Dict[str, Any]] = []
     for schedule, payment, protocol, party, collection, paid in candidates:
         due_date = parse_due_date_ymd(schedule.due_date)
@@ -488,10 +605,14 @@ def list_payment_chase(
                 'samplePaymentId': payment.id,
                 'protocolId': protocol.id,
                 'protocolNo': protocol.protocol_no or '',
+                'protocolStatus': protocol.protocol_status or '',
                 'motherName': (party.mother_name if party else '') or '',
                 'motherPhone': (party.mother_phone if party else '') or '',
                 'sampleCode': payment.sample_code or schedule.sample_code or '',
                 'salesUserId': collection.sales_user_id or '',
+                'salesUserName': sales_user_names.get(
+                    collection.sales_user_id or '', ''
+                ),
                 'payMode': payment.pay_mode or '',
                 'scheduleNo': schedule.schedule_no,
                 'dueDate': due_date.strftime('%Y-%m-%d'),
@@ -792,7 +913,7 @@ def withdraw_transfer(
             db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
         )
         if (row.pay_status or '') != 'jfqrz':
-            raise ValueError('仅缴费确认中可撤回')
+            raise ValueError('仅付款确认中可撤回')
         now = datetime.now()
         changed = (
             db.query(BizProtocolPaymentTransfer)

+ 1 - 1
backend/app/services/protocol_pay_mode_switch.py

@@ -54,7 +54,7 @@ def validate_payment_details_for_save(
     for r in rows:
         status = (r.get("pay_status") or r.get("payStatus") or "dfj").strip()
         if status in BLOCKING_STATUSES:
-            raise ValueError("存在缴费确认中或退费处理中的明细,无法保存")
+            raise ValueError("存在付款确认中或退费处理中的明细,无法保存")
 
     yjf_rows = [r for r in rows if (r.get("pay_status") or r.get("payStatus")) == "yjf"]
 

+ 2 - 2
backend/app/services/protocol_transfer_status.py

@@ -15,12 +15,12 @@ def can_confirm_receipt(pay_status: Optional[str]) -> bool:
 
 
 def can_withdraw_receipt(pay_status: Optional[str]) -> bool:
-    """仅缴费确认中可撤回为待缴费。"""
+    """仅付款确认中可撤回为已付款。"""
     return (pay_status or "").strip() == "jfqrz"
 
 
 def can_edit_transfer(pay_status: Optional[str]) -> bool:
-    """财务确认收款(yjf)前可编辑:待缴费 / 缴费确认中。"""
+    """财务确认收款(yjf)前可编辑:已付款 / 付款确认中。"""
     return (pay_status or "").strip() in ("dfj", "jfqrz")
 
 

+ 3 - 3
backend/app/services/qc_work_order_fee_summary.py

@@ -18,9 +18,9 @@ from app.models.models import (
 
 _PAY_MODE_LABELS = {"dj": "趸交", "fq": "分期", "djfq": "趸交+分期"}
 _PAY_STATUS_LABELS = {
-    "dfj": "待缴费",
-    "jfqrz": "缴费确认中",
-    "yjf": "已缴费",
+    "dfj": "已付款",
+    "jfqrz": "付款确认中",
+    "yjf": "付款完成",
     "tfqrz": "退费确认中",
     "ytf": "已退费",
 }

+ 159 - 20
backend/tests/test_market_presign_api.py

@@ -459,20 +459,106 @@ def test_presign_list_batches_deposits_and_user_names_without_n_plus_one():
 
     assert total == 3
     assert len(items) == 3
-    assert len(statements) <= 4
+    assert len(statements) <= 6
 
 
-def test_presign_response_does_not_leak_invalid_or_cross_tenant_user_names():
-    from app.services.market_presign import list_presign_orders
+def test_presign_list_and_detail_resolve_referenced_master_names_in_batches():
+    from app.services.market_presign import get_presign_order, list_presign_orders
 
     db = _session()
     _add_master_data(db)
-    db.add(
-        _order(
-            "own",
-            sales_user_id="u1",
-            manager_user_id="cross-user",
+    db.add_all(
+        [
+            BizCollectionRegion(id='region-deleted', region_name='历史地区', tenant_no='tenant-a', create_dept_id='', del_flag=1),
+            BizCollectionHospital(id='hospital-deleted', region_id='region-deleted', hospital_name='历史医院', tenant_no='tenant-a', create_dept_id='', del_flag=1),
+            BizCellSampleType(id='sample-deleted', type_code='D', type_name='历史样本', tenant_no='tenant-a', create_dept_id='', del_flag=1),
+            BizCollectionHospital(id='hospital-cross', region_id='region-cross', hospital_name='跨租户医院', tenant_no='tenant-b', create_dept_id='', del_flag=1),
+        ]
+    )
+    normal = _order('normal-master', region_id='region-a', sales_user_id='u1')
+    normal.hospital_id = 'hospital-a'
+    historical = _order(
+        'historical-master', region_id='region-deleted', sales_user_id='u1'
+    )
+    historical.hospital_id = 'hospital-deleted'
+    cross = _order('cross-master', region_id='region-cross', sales_user_id='u1')
+    cross.hospital_id = 'hospital-cross'
+    db.add_all(
+        [
+            normal,
+            historical,
+            cross,
+            BizPresignOrderSampleType(id='normal-master-type', presign_order_id=normal.id, sample_type_id='sample-a', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
+            BizPresignOrderSampleType(id='historical-master-type', presign_order_id=historical.id, sample_type_id='sample-deleted', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
+            BizPresignOrderSampleType(id='cross-master-type', presign_order_id=cross.id, sample_type_id='sample-cross', tenant_no='tenant-a', create_dept_id='', order_num=1, del_flag=0),
+        ]
+    )
+    db.commit()
+    statements = []
+
+    def capture_statement(*args):
+        statements.append(args[2])
+
+    event.listen(db.get_bind(), 'before_cursor_execute', capture_statement)
+    try:
+        items, total = list_presign_orders(
+            db, ctx=_ctx('specialist'), user_info=_user(), page_size=10
         )
+    finally:
+        event.remove(db.get_bind(), 'before_cursor_execute', capture_statement)
+    by_id = {item['id']: item for item in items}
+    normal_detail = get_presign_order(
+        db,
+        order_id=normal.id,
+        ctx=_ctx('specialist'),
+        user_info=_user(),
+    )
+    historical_detail = get_presign_order(
+        db,
+        order_id=historical.id,
+        ctx=_ctx('specialist'),
+        user_info=_user(),
+    )
+
+    assert total == 3
+    assert by_id[normal.id]['regionName'] == 'A区'
+    assert by_id[normal.id]['hospitalName'] == 'A医院'
+    assert by_id[normal.id]['sampleTypeIds'] == ['sample-a']
+    assert by_id[normal.id]['sampleTypeNames'] == ['样本A']
+    assert normal_detail['regionName'] == 'A区'
+    assert normal_detail['hospitalName'] == 'A医院'
+    assert normal_detail['sampleTypeNames'] == ['样本A']
+    assert by_id[historical.id]['regionName'] == '历史地区'
+    assert by_id[historical.id]['hospitalName'] == '历史医院'
+    assert by_id[historical.id]['sampleTypeNames'] == ['历史样本']
+    assert historical_detail['regionName'] == '历史地区'
+    assert historical_detail['hospitalName'] == '历史医院'
+    assert historical_detail['sampleTypeNames'] == ['历史样本']
+    assert by_id[cross.id]['regionName'] == ''
+    assert by_id[cross.id]['hospitalName'] == ''
+    assert by_id[cross.id]['sampleTypeNames'] == ['']
+    assert len(statements) <= 8
+
+
+def test_presign_response_uses_historical_nicknames_without_raw_fallbacks():
+    from app.services.market_presign import list_presign_orders
+
+    db = _session()
+    _add_master_data(db)
+    db.get(SysUser, 'u1').nick_name = ''
+    db.add_all(
+        [
+            _order(
+                "own",
+                sales_user_id="u1",
+                manager_user_id="disabled-user",
+            ),
+            _order(
+                "cross-reference",
+                sales_user_id="u1",
+                manager_user_id="cross-user",
+            ),
+        ]
     )
     row = db.query(BizPresignOrder).filter_by(id="own").one_or_none()
     if row is None:
@@ -481,14 +567,17 @@ def test_presign_response_does_not_leak_invalid_or_cross_tenant_user_names():
     row.collector_user_id = "deleted-user"
     db.commit()
 
-    items, _ = list_presign_orders(
+    items, total = list_presign_orders(
         db, ctx=_ctx("specialist"), user_info=_user()
     )
+    by_id = {item['id']: item for item in items}
 
-    assert items[0]["salesUserName"] == "当前用户"
-    assert items[0]["collectorUserName"] == ""
-    assert items[0]["regionManagerUserName"] == ""
-    assert "秘密姓名" not in str(items[0])
+    assert total == 2
+    assert by_id['own']["salesUserName"] == ""
+    assert by_id['own']["collectorUserName"] == "已删除秘密姓名"
+    assert by_id['own']["regionManagerUserName"] == "停用秘密姓名"
+    assert by_id['cross-reference']["regionManagerUserName"] == ""
+    assert "跨租户秘密姓名" not in str(items)
 
 
 def test_presign_detail_rejects_guessed_out_of_scope_id():
@@ -509,6 +598,25 @@ def test_presign_detail_rejects_guessed_out_of_scope_id():
     assert exc_info.value.status_code == 404
 
 
+def test_presign_body_rejects_client_controlled_sales_user():
+    from pydantic import ValidationError
+
+    from app.schemas.market_mobile import MarketPresignOrderBody
+
+    with pytest.raises(ValidationError):
+        MarketPresignOrderBody.model_validate({'salesUserId': 'attacker-selected-user'})
+
+
+@pytest.mark.parametrize('payload', [{'motherName': '   '}, {'sampleTypeIds': []}])
+def test_presign_update_body_rejects_explicitly_empty_required_fields(payload):
+    from pydantic import ValidationError
+
+    from app.schemas.market_mobile import MarketPresignOrderBody
+
+    with pytest.raises(ValidationError):
+        MarketPresignOrderBody.model_validate(payload)
+
+
 def test_specialist_create_forces_current_sales_user_and_accepts_order_status():
     from app.schemas.market_mobile import MarketPresignOrderBody
     from app.services.market_presign import create_presign_order
@@ -521,7 +629,6 @@ def test_specialist_create_forces_current_sales_user_and_accepts_order_status():
             "regionId": "region-a",
             "hospitalId": "hospital-a",
             "sampleTypeIds": ["sample-a"],
-            "salesUserId": "attacker-selected-user",
             "orderStatus": "void",
         }
     )
@@ -709,27 +816,60 @@ def test_create_rejects_cross_tenant_deleted_or_disabled_person(user_id):
     assert db.query(BizPresignOrder).count() == 0
 
 
-def test_manager_create_validates_sales_user_even_when_assignment_is_valid():
+def test_manager_create_cannot_self_assign_an_unmanaged_region():
     from app.schemas.market_mobile import MarketPresignOrderBody
     from app.services.market_presign import create_presign_order
 
     db = _session()
     _add_master_data(db)
+    db.add(
+        BizCollectionRegionStaff(
+            id='manager-region-a',
+            region_id='region-a',
+            user_id='u1',
+            is_leader=1,
+            tenant_no='tenant-a',
+            create_dept_id='dept-a',
+            del_flag=0,
+        )
+    )
+    db.commit()
 
-    with pytest.raises(ValueError, match="用户"):
+    with pytest.raises(ValueError, match="地区经理范围"):
         create_presign_order(
             db,
             body=MarketPresignOrderBody.model_validate(
                 {
-                    "regionId": "region-a",
-                    "salesUserId": "cross-user",
+                    "motherName": "越权订单",
+                    "regionId": "region-b",
                     "regionManagerUserId": "u1",
+                    "sampleTypeIds": ["sample-a"],
                 }
             ),
             ctx=_ctx("manager"),
             user_info=_user(),
         )
 
+    assert db.query(BizPresignOrder).count() == 0
+
+
+@pytest.mark.parametrize(
+    'payload',
+    [
+        {'sampleTypeIds': ['sample-a']},
+        {'motherName': '   ', 'sampleTypeIds': ['sample-a']},
+        {'motherName': '测试客户'},
+        {'motherName': '测试客户', 'sampleTypeIds': []},
+    ],
+)
+def test_create_requires_mother_name_and_sample_types(payload):
+    from pydantic import ValidationError
+
+    from app.schemas.market_mobile import MarketPresignOrderCreateBody
+
+    with pytest.raises(ValidationError, match='motherName|sampleTypeIds|母亲姓名'):
+        MarketPresignOrderCreateBody.model_validate(payload)
+
 
 @pytest.mark.parametrize(
     ("payload", "message"),
@@ -1160,7 +1300,6 @@ def test_market_presign_order_body_accepts_database_sized_values():
             'payMode': 'x' * 8,
             'contractAmount': '9999999999999999.99',
             'collectorUserId': 'c' * 64,
-            'salesUserId': 's' * 64,
             'regionManagerUserId': 'm' * 64,
             'remark': '备' * 512,
             'sampleTypeIds': [f'sample-{index}' for index in range(100)],
@@ -1451,7 +1590,7 @@ def test_dfj_presign_transfer_can_edit_submit_withdraw_and_soft_delete():
         user_info=_user(),
     )
     assert submitted['payStatus'] == 'jfqrz'
-    with pytest.raises(ValueError, match='状态|待缴费|已缴费'):
+    with pytest.raises(ValueError, match='状态|待缴费|已缴费|已付款'):
         delete_presign_transfer(
             db,
             transfer_id=transfer_id,

+ 1 - 1
backend/tests/test_protocol_sample_payment.py

@@ -25,7 +25,7 @@ class TestTransferStatusRules(unittest.TestCase):
         self.assertFalse(can_confirm_receipt("yjf"))
 
     def test_edit_before_finance_confirm(self):
-        """待缴费、缴费确认中可编辑;已确认收款不可编辑。"""
+        """已付款、付款确认中可编辑;付款完成不可编辑。"""
         self.assertTrue(can_edit_transfer("dfj"))
         self.assertTrue(can_edit_transfer("jfqrz"))
         self.assertFalse(can_edit_transfer("yjf"))

+ 1 - 1
backend/tests/test_sample_payment_fgd_convert.py

@@ -27,7 +27,7 @@ class TestFgdConvert(unittest.TestCase):
         self.assertEqual(plan["newInstallmentCount"], 3)
 
     def test_allow_when_jfqrz(self):
-        """缴费确认中不再阻断分改趸并期规划。"""
+        """付款确认中不再阻断分改趸并期规划。"""
         schedules = [
             {"id": "s1", "scheduleNo": 1, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},
             {"id": "s2", "scheduleNo": 2, "dueAmount": Decimal("1000"), "scheduleStatus": "open"},