fix(backend): align pay-status user messages with 付款 wording
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a5bf4252b9
commit
727002761d
11 files changed
+359
-66
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": "已退费",
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user