feat: 抽出顺丰月结的单号、金额和结算规则,供网页与市场端共用
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ac8e471aca
commit
9325f8cf61
2 files changed
+306
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"""顺丰月结抬头与结算规则。不访问数据库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any, Optional
|
||||
|
||||
SF_TRANSPORT_MODE = "sfkd"
|
||||
SETTLE_UNSETTLED = "wjs"
|
||||
SETTLE_SETTLED = "yjs"
|
||||
_AMOUNT_QUANT = Decimal("0.01")
|
||||
_MAX_AMOUNT = Decimal("9999999999999999.99")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreightHeaderPlan:
|
||||
transport_mode: str
|
||||
waybill_no: Optional[str]
|
||||
freight_amount: Optional[Decimal]
|
||||
settle_status: Optional[str]
|
||||
clear_settle_time: bool
|
||||
|
||||
|
||||
def normalize_waybill(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _is_blank(value: Any) -> bool:
|
||||
return value is None or (isinstance(value, str) and value.strip() == "")
|
||||
|
||||
|
||||
def parse_optional_amount(value: Any) -> Optional[Decimal]:
|
||||
if _is_blank(value):
|
||||
return None
|
||||
try:
|
||||
amount = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise ValueError("运费金额格式不正确")
|
||||
if not amount.is_finite():
|
||||
raise ValueError("运费金额格式不正确")
|
||||
if amount < 0:
|
||||
raise ValueError("运费不能为负数")
|
||||
if amount > _MAX_AMOUNT:
|
||||
raise ValueError("运费金额超出范围")
|
||||
return amount.quantize(_AMOUNT_QUANT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def freight_amount_label(transport_mode: Optional[str], amount: Optional[Decimal]) -> str:
|
||||
if (transport_mode or "") == SF_TRANSPORT_MODE and amount is None:
|
||||
return "月结"
|
||||
if amount is None:
|
||||
return ""
|
||||
return format(Decimal(amount).quantize(_AMOUNT_QUANT, rounding=ROUND_HALF_UP), "f")
|
||||
|
||||
|
||||
def plan_freight_header(
|
||||
*,
|
||||
transport_mode: str,
|
||||
waybill_no: Any = None,
|
||||
freight_amount: Any = None,
|
||||
) -> FreightHeaderPlan:
|
||||
mode = str(transport_mode or "").strip()
|
||||
if not mode:
|
||||
raise ValueError("请选择运输方式")
|
||||
if mode == SF_TRANSPORT_MODE:
|
||||
waybill = normalize_waybill(waybill_no)
|
||||
if not waybill:
|
||||
raise ValueError("请填写顺丰单号")
|
||||
if len(waybill) > 64:
|
||||
raise ValueError("顺丰单号不能超过 64 个字符")
|
||||
return FreightHeaderPlan(mode, waybill, None, SETTLE_UNSETTLED, True)
|
||||
if normalize_waybill(waybill_no):
|
||||
raise ValueError("该运输方式不能填写顺丰单号")
|
||||
amount = parse_optional_amount(freight_amount)
|
||||
if amount is None:
|
||||
raise ValueError("请填写运费")
|
||||
return FreightHeaderPlan(mode, None, amount, None, True)
|
||||
|
||||
|
||||
def resolve_header_for_save(
|
||||
*,
|
||||
transport_mode: str,
|
||||
existing_waybill: Any,
|
||||
existing_amount: Any,
|
||||
existing_settle_status: Any,
|
||||
waybill_supplied: bool,
|
||||
waybill_no: Any,
|
||||
amount_supplied: bool,
|
||||
freight_amount: Any,
|
||||
) -> FreightHeaderPlan:
|
||||
if (existing_settle_status or "") == SETTLE_SETTLED:
|
||||
raise ValueError("已结算的运费不能修改运输方式或顺丰单号")
|
||||
if transport_mode == SF_TRANSPORT_MODE:
|
||||
waybill = waybill_no if waybill_supplied else existing_waybill
|
||||
return plan_freight_header(
|
||||
transport_mode=transport_mode, waybill_no=waybill, freight_amount=None
|
||||
)
|
||||
amount = freight_amount if amount_supplied else existing_amount
|
||||
return plan_freight_header(
|
||||
transport_mode=transport_mode, waybill_no="", freight_amount=amount
|
||||
)
|
||||
|
||||
|
||||
def plan_settle(*, current_amount: Optional[Decimal], submitted_amount: Any, now: datetime):
|
||||
amount = current_amount if _is_blank(submitted_amount) else parse_optional_amount(submitted_amount)
|
||||
return amount, SETTLE_SETTLED, now
|
||||
|
||||
|
||||
def assert_settle_request(ids: list, submitted_amount: Any) -> None:
|
||||
if not ids:
|
||||
raise ValueError("请选择运费批次")
|
||||
if len(ids) != 1 and not _is_blank(submitted_amount):
|
||||
raise ValueError("批量结算不能填写运费")
|
||||
|
||||
|
||||
def plan_fill_amount(*, current_amount: Optional[Decimal], settle_status: Optional[str], submitted_amount: Any) -> Decimal:
|
||||
if settle_status != SETTLE_SETTLED or current_amount is not None:
|
||||
raise ValueError("只有已结算且仍为月结的运费可以补填金额")
|
||||
amount = parse_optional_amount(submitted_amount)
|
||||
if amount is None:
|
||||
raise ValueError("请填写运费")
|
||||
return amount
|
||||
|
||||
|
||||
def assert_freight_mutable(settle_status: Optional[str], action: str) -> None:
|
||||
if (settle_status or "") != SETTLE_SETTLED:
|
||||
return
|
||||
if action == "delete":
|
||||
raise ValueError("已结算的运费不能删除")
|
||||
if action == "items":
|
||||
raise ValueError("已结算的运费不能调整样本")
|
||||
raise ValueError("已结算的运费不能修改运输方式或顺丰单号")
|
||||
@@ -0,0 +1,173 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.sf_express_settlement import (
|
||||
assert_freight_mutable,
|
||||
assert_settle_request,
|
||||
freight_amount_label,
|
||||
plan_fill_amount,
|
||||
plan_settle,
|
||||
resolve_header_for_save,
|
||||
)
|
||||
|
||||
|
||||
def test_new_sf_express_nulls_amount_and_requires_waybill():
|
||||
plan = resolve_header_for_save(
|
||||
transport_mode="sfkd",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no=" SF001 ",
|
||||
amount_supplied=True,
|
||||
freight_amount=Decimal("88"),
|
||||
)
|
||||
assert plan.waybill_no == "SF001"
|
||||
assert plan.freight_amount is None
|
||||
assert plan.settle_status == "wjs"
|
||||
assert plan.clear_settle_time is True
|
||||
assert freight_amount_label("sfkd", plan.freight_amount) == "月结"
|
||||
|
||||
|
||||
def test_sf_express_rejects_blank_and_long_waybill():
|
||||
with pytest.raises(ValueError, match="请填写顺丰单号"):
|
||||
resolve_header_for_save(
|
||||
transport_mode="sfkd",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no=" ",
|
||||
amount_supplied=True,
|
||||
freight_amount=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="顺丰单号不能超过 64 个字符"):
|
||||
resolve_header_for_save(
|
||||
transport_mode="sfkd",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no="S" * 65,
|
||||
amount_supplied=False,
|
||||
freight_amount=None,
|
||||
)
|
||||
|
||||
|
||||
def test_non_sf_requires_amount_and_clears_waybill_and_settlement():
|
||||
plan = resolve_header_for_save(
|
||||
transport_mode="zxc",
|
||||
existing_waybill="SF001",
|
||||
existing_amount=None,
|
||||
existing_settle_status="wjs",
|
||||
waybill_supplied=False,
|
||||
waybill_no=None,
|
||||
amount_supplied=True,
|
||||
freight_amount="12.3",
|
||||
)
|
||||
assert plan.waybill_no is None
|
||||
assert plan.freight_amount == Decimal("12.30")
|
||||
assert plan.settle_status is None
|
||||
assert plan.clear_settle_time is True
|
||||
|
||||
|
||||
def test_other_mode_named_sf_still_requires_amount():
|
||||
plan = resolve_header_for_save(
|
||||
transport_mode="sf",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no="",
|
||||
amount_supplied=True,
|
||||
freight_amount="12.30",
|
||||
)
|
||||
assert plan.freight_amount == Decimal("12.30")
|
||||
assert plan.waybill_no is None
|
||||
assert plan.settle_status is None
|
||||
|
||||
|
||||
def test_non_sf_rejects_missing_amount_and_negative_amount():
|
||||
with pytest.raises(ValueError, match="请填写运费"):
|
||||
resolve_header_for_save(
|
||||
transport_mode="zxc",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no="",
|
||||
amount_supplied=True,
|
||||
freight_amount=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="运费不能为负数"):
|
||||
resolve_header_for_save(
|
||||
transport_mode="zxc",
|
||||
existing_waybill=None,
|
||||
existing_amount=None,
|
||||
existing_settle_status=None,
|
||||
waybill_supplied=True,
|
||||
waybill_no="",
|
||||
amount_supplied=True,
|
||||
freight_amount="-1",
|
||||
)
|
||||
|
||||
|
||||
def test_settled_header_cannot_change():
|
||||
with pytest.raises(ValueError, match="已结算的运费不能修改运输方式或顺丰单号"):
|
||||
resolve_header_for_save(
|
||||
transport_mode="sfkd",
|
||||
existing_waybill="SF001",
|
||||
existing_amount=None,
|
||||
existing_settle_status="yjs",
|
||||
waybill_supplied=True,
|
||||
waybill_no="SF002",
|
||||
amount_supplied=False,
|
||||
freight_amount=None,
|
||||
)
|
||||
|
||||
|
||||
def test_blank_settle_keeps_null_and_historical_amount():
|
||||
now = datetime(2026, 10, 21, 12, 0, 0)
|
||||
assert plan_settle(current_amount=None, submitted_amount=" ", now=now) == (
|
||||
None,
|
||||
"yjs",
|
||||
now,
|
||||
)
|
||||
assert plan_settle(current_amount=Decimal("10.00"), submitted_amount=None, now=now) == (
|
||||
Decimal("10.00"),
|
||||
"yjs",
|
||||
now,
|
||||
)
|
||||
amount, status, settle_time = plan_settle(
|
||||
current_amount=Decimal("10.00"), submitted_amount="20", now=now
|
||||
)
|
||||
assert amount == Decimal("20.00")
|
||||
assert status == "yjs"
|
||||
assert settle_time == now
|
||||
|
||||
|
||||
def test_batch_settle_rejects_amount_and_fill_keeps_only_monthly_rows():
|
||||
assert_settle_request(["a", "b"], None)
|
||||
with pytest.raises(ValueError, match="批量结算不能填写运费"):
|
||||
assert_settle_request(["a", "b"], "10")
|
||||
with pytest.raises(ValueError, match="请选择运费批次"):
|
||||
assert_settle_request([], None)
|
||||
assert plan_fill_amount(
|
||||
current_amount=None, settle_status="yjs", submitted_amount="6.5"
|
||||
) == Decimal("6.50")
|
||||
with pytest.raises(ValueError, match="只有已结算且仍为月结的运费可以补填金额"):
|
||||
plan_fill_amount(
|
||||
current_amount=Decimal("1.00"), settle_status="yjs", submitted_amount="2"
|
||||
)
|
||||
|
||||
|
||||
def test_settled_batch_cannot_delete_or_change_samples():
|
||||
with pytest.raises(ValueError, match="已结算的运费不能删除"):
|
||||
assert_freight_mutable("yjs", "delete")
|
||||
with pytest.raises(ValueError, match="已结算的运费不能调整样本"):
|
||||
assert_freight_mutable("yjs", "items")
|
||||
assert_freight_mutable("wjs", "delete")
|
||||
assert freight_amount_label("sfkd", Decimal("8")) == "8.00"
|
||||
assert freight_amount_label("zxc", None) == ""
|
||||
Reference in new issue
Block a user