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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-12 18:10:14 +08:00
1 parent a5bf4252b9
commit 727002761d
11 files changed
+359 -66

No files matched your search

+1 -1
View File
@@ -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"
+42 -14
View File
@@ -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()
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,
)
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)
if commit:
db.commit()
else:
db.flush()
def get_storage_cert(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolStorageCert]:
+19 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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"]
@@ -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")
@@ -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": "已退费",
}
+155 -16
View File
@@ -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(
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,
@@ -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"))
@@ -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"},