diff --git a/backend/app/services/sf_express_settlement.py b/backend/app/services/sf_express_settlement.py new file mode 100644 index 00000000..a5997b8c --- /dev/null +++ b/backend/app/services/sf_express_settlement.py @@ -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("已结算的运费不能修改运输方式或顺丰单号") diff --git a/backend/tests/test_sf_express_settlement.py b/backend/tests/test_sf_express_settlement.py new file mode 100644 index 00000000..8745f91d --- /dev/null +++ b/backend/tests/test_sf_express_settlement.py @@ -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) == ""