From 727002761d6af1c57eda0dc0f25249bc07eb6ebd Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 12 Oct 2026 18:10:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20align=20pay-status=20user=20mes?= =?UTF-8?q?sages=20with=20=E4=BB=98=E6=AC=BE=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/app/crud/clinical_payment.py | 2 +- backend/app/crud/collection_protocol.py | 62 ++++-- backend/app/crud/presign_order.py | 33 ++-- backend/app/crud/protocol_sample_payment.py | 10 +- backend/app/services/market_payment.py | 137 +++++++++++++- .../app/services/protocol_pay_mode_switch.py | 2 +- .../app/services/protocol_transfer_status.py | 4 +- .../app/services/qc_work_order_fee_summary.py | 6 +- backend/tests/test_market_presign_api.py | 179 ++++++++++++++++-- backend/tests/test_protocol_sample_payment.py | 2 +- .../tests/test_sample_payment_fgd_convert.py | 2 +- 11 files changed, 366 insertions(+), 73 deletions(-) diff --git a/backend/app/crud/clinical_payment.py b/backend/app/crud/clinical_payment.py index 0d1220fa..cef17fa2 100644 --- a/backend/app/crud/clinical_payment.py +++ b/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" diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index b51a8688..d3292555 100644 --- a/backend/app/crud/collection_protocol.py +++ b/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]: diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index 90b3ba23..76aa66ea 100644 --- a/backend/app/crud/presign_order.py +++ b/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, diff --git a/backend/app/crud/protocol_sample_payment.py b/backend/app/crud/protocol_sample_payment.py index 9b48fde4..81785189 100644 --- a/backend/app/crud/protocol_sample_payment.py +++ b/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() diff --git a/backend/app/services/market_payment.py b/backend/app/services/market_payment.py index c3f201d4..57d3ba2e 100644 --- a/backend/app/services/market_payment.py +++ b/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) diff --git a/backend/app/services/protocol_pay_mode_switch.py b/backend/app/services/protocol_pay_mode_switch.py index 1e543760..41b75009 100644 --- a/backend/app/services/protocol_pay_mode_switch.py +++ b/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"] diff --git a/backend/app/services/protocol_transfer_status.py b/backend/app/services/protocol_transfer_status.py index b3cd4d82..46b7f680 100644 --- a/backend/app/services/protocol_transfer_status.py +++ b/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") diff --git a/backend/app/services/qc_work_order_fee_summary.py b/backend/app/services/qc_work_order_fee_summary.py index fa4ff589..9962603d 100644 --- a/backend/app/services/qc_work_order_fee_summary.py +++ b/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": "已退费", } diff --git a/backend/tests/test_market_presign_api.py b/backend/tests/test_market_presign_api.py index 81420c79..4cf73d89 100644 --- a/backend/tests/test_market_presign_api.py +++ b/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(): +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_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.add( - _order( - "own", - sales_user_id="u1", - manager_user_id="cross-user", - ) + 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, diff --git a/backend/tests/test_protocol_sample_payment.py b/backend/tests/test_protocol_sample_payment.py index 65848c66..ad5bf460 100644 --- a/backend/tests/test_protocol_sample_payment.py +++ b/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")) diff --git a/backend/tests/test_sample_payment_fgd_convert.py b/backend/tests/test_sample_payment_fgd_convert.py index 959e09ba..f780cd5a 100644 --- a/backend/tests/test_sample_payment_fgd_convert.py +++ b/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"},