586 lines
19 KiB
Python
586 lines
19 KiB
Python
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) == ""
|
|
|
|
|
|
def test_freight_model_allows_null_amount_and_settlement_columns():
|
|
from app.models.models import BizSampleFreight
|
|
|
|
cols = {column.name: column for column in BizSampleFreight.__table__.columns}
|
|
assert cols["freight_amount"].nullable is True
|
|
assert cols["waybill_no"].nullable is True
|
|
assert cols["settle_status"].nullable is True
|
|
assert cols["settle_time"].nullable is True
|
|
|
|
|
|
def test_amount_serializer_keeps_null_instead_of_zero():
|
|
from types import SimpleNamespace
|
|
from app.crud.sample_freight import _row_to_dict
|
|
from app.services.market_freight import _batch_dict
|
|
|
|
obj = SimpleNamespace(
|
|
id="f1",
|
|
freight_no="YF1",
|
|
transport_mode="sfkd",
|
|
freight_amount=None,
|
|
waybill_no=None,
|
|
settle_status="wjs",
|
|
settle_time=None,
|
|
tenant_no="zkxw",
|
|
order_num=0,
|
|
remark="",
|
|
create_by="u1",
|
|
create_time=None,
|
|
update_time=None,
|
|
)
|
|
admin = _row_to_dict(obj, transport_mode_label="顺丰快递", create_by_name="张三")
|
|
market = _batch_dict(obj, 1, "顺丰快递", "张三")
|
|
assert admin["freightAmount"] is None
|
|
assert admin["waybillNo"] == ""
|
|
assert admin["settleStatus"] == "wjs"
|
|
assert admin["freightAmountLabel"] == "月结"
|
|
assert market["freightAmount"] is None
|
|
assert market["freightAmountLabel"] == "月结"
|
|
|
|
|
|
def test_sample_card_payload_keeps_null_amount():
|
|
from app.crud.sample_freight import freight_amount_payload
|
|
|
|
monthly = freight_amount_payload(None, "sfkd", True)
|
|
assert monthly["freightAmount"] is None
|
|
assert monthly["freightAmountLabel"] == "月结"
|
|
assert freight_amount_payload(None, "sfkd", False)["freightAmount"] is None
|
|
assert freight_amount_payload(None, "zxc", True)["freightAmountLabel"] == ""
|
|
|
|
numeric = freight_amount_payload(Decimal("88"), "zxc", True)
|
|
assert numeric["freightAmount"] == "88.00"
|
|
assert numeric["freightAmountLabel"] == "88.00"
|
|
assert freight_amount_payload(Decimal("0"), "zxc", False)["freightAmount"] == 0.0
|
|
|
|
|
|
def test_admin_and_market_bodies_accept_sf_without_amount():
|
|
from app.api.sample_freight import SampleFreightCreateBody, SampleFreightUpdateBody
|
|
from app.schemas.market_mobile import (
|
|
MarketFreightCreateBody,
|
|
MarketFreightUpdateBody,
|
|
MarketProtocolSampleFreightDraftBody,
|
|
)
|
|
|
|
admin = SampleFreightCreateBody.model_validate({
|
|
"transportMode": "sfkd",
|
|
"waybillNo": "SF100",
|
|
"items": [{"sampleSource": "cell_storage", "sampleId": "s1"}],
|
|
})
|
|
assert admin.freight_amount is None
|
|
assert admin.waybill_no == "SF100"
|
|
updated = SampleFreightUpdateBody.model_validate({"waybillNo": "SF101"})
|
|
assert "waybill_no" in updated.model_fields_set
|
|
market = MarketFreightCreateBody.model_validate({
|
|
"transportMode": "sfkd",
|
|
"waybillNo": "SF100",
|
|
"items": [{"sampleSource": "cell_storage", "sampleId": "s1"}],
|
|
})
|
|
assert market.freight_amount is None
|
|
assert market.waybill_no == "SF100"
|
|
draft = MarketProtocolSampleFreightDraftBody.model_validate({
|
|
"clientKey": "fd_1",
|
|
"transportMode": "sfkd",
|
|
"waybillNo": "SF100",
|
|
})
|
|
assert draft.waybill_no == "SF100"
|
|
MarketFreightUpdateBody.model_validate({"waybillNo": "SF100"})
|
|
|
|
|
|
def _protocol_freight_db():
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.models.base import Base
|
|
from app.models.models import (
|
|
BizCellSampleType,
|
|
BizCollectionProtocol,
|
|
BizProtocolPartyCustomer,
|
|
BizProtocolSample,
|
|
BizSampleFreight,
|
|
BizSampleFreightItem,
|
|
SysDictData,
|
|
)
|
|
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[
|
|
BizSampleFreight.__table__,
|
|
BizSampleFreightItem.__table__,
|
|
BizProtocolSample.__table__,
|
|
BizCollectionProtocol.__table__,
|
|
BizCellSampleType.__table__,
|
|
BizProtocolPartyCustomer.__table__,
|
|
SysDictData.__table__,
|
|
],
|
|
)
|
|
return sessionmaker(bind=engine)()
|
|
|
|
|
|
def _seed_settled_protocol_freight(db, sample_id="s-old"):
|
|
from app.models.models import BizSampleFreight, BizSampleFreightItem
|
|
|
|
now = datetime(2026, 10, 21, 9, 0, 0)
|
|
freight = BizSampleFreight(
|
|
id="f-yjs",
|
|
freight_no="YF1",
|
|
transport_mode="sfkd",
|
|
freight_amount=None,
|
|
waybill_no="SF001",
|
|
settle_status="yjs",
|
|
tenant_no="tenant-a",
|
|
create_dept_id="",
|
|
create_time=now,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
item = BizSampleFreightItem(
|
|
id="fi-1",
|
|
freight_id=freight.id,
|
|
sample_source="cell_storage",
|
|
sample_id=sample_id,
|
|
sample_code="C1",
|
|
protocol_id="p1",
|
|
tenant_no="tenant-a",
|
|
create_dept_id="",
|
|
create_time=now,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
db.add_all([freight, item])
|
|
db.commit()
|
|
return freight, item
|
|
|
|
|
|
def test_settled_protocol_save_without_freight_id_keeps_links():
|
|
from types import SimpleNamespace
|
|
|
|
from app.crud.sample_freight import apply_freight_on_protocol_save
|
|
from app.models.models import BizSampleFreight, BizSampleFreightItem
|
|
|
|
db = _protocol_freight_db()
|
|
freight, item = _seed_settled_protocol_freight(db)
|
|
try:
|
|
with pytest.raises(ValueError, match="已结算的运费不能调整样本"):
|
|
apply_freight_on_protocol_save(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
sample_source="cell_storage",
|
|
protocol_id="p1",
|
|
saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")],
|
|
input_samples=[{}],
|
|
)
|
|
db.expire_all()
|
|
item = db.query(BizSampleFreightItem).filter_by(id=item.id).one()
|
|
freight = db.query(BizSampleFreight).filter_by(id=freight.id).one()
|
|
assert item.del_flag == 0
|
|
assert freight.del_flag == 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_settled_protocol_save_relinks_same_count_to_new_sample():
|
|
from types import SimpleNamespace
|
|
|
|
from app.crud.sample_freight import apply_freight_on_protocol_save
|
|
from app.models.models import BizSampleFreight, BizSampleFreightItem
|
|
|
|
db = _protocol_freight_db()
|
|
freight, _item = _seed_settled_protocol_freight(db)
|
|
try:
|
|
apply_freight_on_protocol_save(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
sample_source="cell_storage",
|
|
protocol_id="p1",
|
|
saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")],
|
|
input_samples=[{"freightId": freight.id}],
|
|
)
|
|
db.expire_all()
|
|
freight = db.query(BizSampleFreight).filter_by(id=freight.id).one()
|
|
assert freight.del_flag == 0
|
|
assert freight.settle_status == "yjs"
|
|
live = (
|
|
db.query(BizSampleFreightItem)
|
|
.filter_by(freight_id=freight.id, sample_id="s-new", del_flag=0)
|
|
.one()
|
|
)
|
|
assert live.protocol_id == "p1"
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from app.models.base import Base
|
|
from app.models.models import BizSampleFreight, SysUser
|
|
|
|
|
|
def _amount_equals(actual, expected: str) -> None:
|
|
"""SQLite 可能把 Numeric 读成 float,按数值比较,空金额必须保持 None。"""
|
|
assert actual is not None
|
|
assert Decimal(str(actual)) == Decimal(expected)
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[SysUser.__table__, BizSampleFreight.__table__],
|
|
)
|
|
session = sessionmaker(bind=engine)()
|
|
now = datetime(2026, 10, 21, 9, 0, 0)
|
|
common = dict(
|
|
tenant_no="tenant-a",
|
|
create_dept_id="d1",
|
|
create_time=now,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
session.add(
|
|
SysUser(
|
|
id="u1",
|
|
user_no="u1",
|
|
nick_name="张三",
|
|
email="",
|
|
phone_no="",
|
|
password="",
|
|
status="1",
|
|
dept_id="d1",
|
|
**common,
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
BizSampleFreight(
|
|
id="sf-null",
|
|
freight_no="YF1",
|
|
transport_mode="sfkd",
|
|
freight_amount=None,
|
|
waybill_no="SF001",
|
|
settle_status="wjs",
|
|
create_by="u1",
|
|
**common,
|
|
),
|
|
BizSampleFreight(
|
|
id="sf-old",
|
|
freight_no="YF2",
|
|
transport_mode="sfkd",
|
|
freight_amount=Decimal("10.00"),
|
|
waybill_no="",
|
|
settle_status="wjs",
|
|
create_by="u1",
|
|
**common,
|
|
),
|
|
BizSampleFreight(
|
|
id="line",
|
|
freight_no="YF3",
|
|
transport_mode="zxc",
|
|
freight_amount=Decimal("5.00"),
|
|
create_by="u1",
|
|
**common,
|
|
),
|
|
BizSampleFreight(
|
|
id="other-tenant",
|
|
freight_no="YF4",
|
|
transport_mode="sfkd",
|
|
freight_amount=None,
|
|
waybill_no="SF999",
|
|
settle_status="wjs",
|
|
create_by="u1",
|
|
tenant_no="tenant-b",
|
|
create_dept_id="d1",
|
|
create_time=now,
|
|
update_time=now,
|
|
del_flag=0,
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
USER = {
|
|
"user_id": "u1",
|
|
"tenant_no": "tenant-a",
|
|
"role": "super_admin",
|
|
"dept_id": "d1",
|
|
}
|
|
|
|
|
|
def test_list_returns_only_current_tenant_sf_rows(db):
|
|
from app.crud.sf_express_settlement import list_sf_settlements
|
|
|
|
rows, total = list_sf_settlements(
|
|
db, tenant_no="tenant-a", user_info=USER, page=1, page_size=20
|
|
)
|
|
assert total == 2
|
|
assert {row["id"] for row in rows} == {"sf-null", "sf-old"}
|
|
assert all(row["transportMode"] == "sfkd" for row in rows)
|
|
null_row = next(row for row in rows if row["id"] == "sf-null")
|
|
assert null_row["freightAmountLabel"] == "月结"
|
|
assert null_row["createByName"] == "张三"
|
|
assert null_row["waybillNo"] == "SF001"
|
|
|
|
|
|
def test_settle_blank_keeps_null_and_historical_amount(db):
|
|
from app.crud.sf_express_settlement import settle_sf_freights
|
|
|
|
settle_sf_freights(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
user_info=USER,
|
|
ids=["sf-null"],
|
|
freight_amount=None,
|
|
)
|
|
settle_sf_freights(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
user_info=USER,
|
|
ids=["sf-old"],
|
|
freight_amount="",
|
|
)
|
|
assert db.query(BizSampleFreight).filter_by(id="sf-null").one().freight_amount is None
|
|
_amount_equals(
|
|
db.query(BizSampleFreight).filter_by(id="sf-old").one().freight_amount, "10"
|
|
)
|
|
assert db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_status == "yjs"
|
|
with pytest.raises(ValueError, match="该运费已结算"):
|
|
settle_sf_freights(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
user_info=USER,
|
|
ids=["sf-null"],
|
|
freight_amount=None,
|
|
)
|
|
|
|
|
|
def test_fill_amount_does_not_change_settle_time(db):
|
|
from app.crud.sf_express_settlement import fill_sf_freight_amount, settle_sf_freights
|
|
|
|
settle_sf_freights(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
user_info=USER,
|
|
ids=["sf-null"],
|
|
freight_amount=None,
|
|
)
|
|
settled_at = db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_time
|
|
fill_sf_freight_amount(
|
|
db,
|
|
tenant_no="tenant-a",
|
|
user_id="u1",
|
|
user_info=USER,
|
|
freight_id="sf-null",
|
|
freight_amount="15",
|
|
)
|
|
row = db.query(BizSampleFreight).filter_by(id="sf-null").one()
|
|
_amount_equals(row.freight_amount, "15.00")
|
|
assert row.settle_time == settled_at
|
|
|
|
|
|
def test_export_empty_filter_raises_chinese_message(db):
|
|
from app.crud.sf_express_settlement import list_sf_settlements_for_export
|
|
|
|
with pytest.raises(ValueError, match="没有可导出的数据"):
|
|
list_sf_settlements_for_export(
|
|
db, tenant_no="tenant-a", user_info=USER, waybill_no="不存在"
|
|
)
|
|
|
|
|
|
def test_market_router_has_no_settlement_paths():
|
|
from app.api.market_mobile import router as market_router
|
|
from app.api.sample_freight import router as admin_router
|
|
|
|
market_paths = [getattr(route, "path", "") for route in market_router.routes]
|
|
admin_paths = [getattr(route, "path", "") for route in admin_router.routes]
|
|
assert all("sf-settlement" not in path for path in market_paths)
|
|
assert "/web/admin/sample-freights/sf-settlements" in admin_paths
|
|
assert "/web/admin/sample-freights/sf-settlements/export" in admin_paths
|