test_sf_express_settlement.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. from datetime import datetime
  2. from decimal import Decimal
  3. import pytest
  4. from app.services.sf_express_settlement import (
  5. assert_freight_mutable,
  6. assert_settle_request,
  7. freight_amount_label,
  8. plan_fill_amount,
  9. plan_settle,
  10. resolve_header_for_save,
  11. )
  12. def test_new_sf_express_nulls_amount_and_requires_waybill():
  13. plan = resolve_header_for_save(
  14. transport_mode="sfkd",
  15. existing_waybill=None,
  16. existing_amount=None,
  17. existing_settle_status=None,
  18. waybill_supplied=True,
  19. waybill_no=" SF001 ",
  20. amount_supplied=True,
  21. freight_amount=Decimal("88"),
  22. )
  23. assert plan.waybill_no == "SF001"
  24. assert plan.freight_amount is None
  25. assert plan.settle_status == "wjs"
  26. assert plan.clear_settle_time is True
  27. assert freight_amount_label("sfkd", plan.freight_amount) == "月结"
  28. def test_sf_express_rejects_blank_and_long_waybill():
  29. with pytest.raises(ValueError, match="请填写顺丰单号"):
  30. resolve_header_for_save(
  31. transport_mode="sfkd",
  32. existing_waybill=None,
  33. existing_amount=None,
  34. existing_settle_status=None,
  35. waybill_supplied=True,
  36. waybill_no=" ",
  37. amount_supplied=True,
  38. freight_amount=None,
  39. )
  40. with pytest.raises(ValueError, match="顺丰单号不能超过 64 个字符"):
  41. resolve_header_for_save(
  42. transport_mode="sfkd",
  43. existing_waybill=None,
  44. existing_amount=None,
  45. existing_settle_status=None,
  46. waybill_supplied=True,
  47. waybill_no="S" * 65,
  48. amount_supplied=False,
  49. freight_amount=None,
  50. )
  51. def test_non_sf_requires_amount_and_clears_waybill_and_settlement():
  52. plan = resolve_header_for_save(
  53. transport_mode="zxc",
  54. existing_waybill="SF001",
  55. existing_amount=None,
  56. existing_settle_status="wjs",
  57. waybill_supplied=False,
  58. waybill_no=None,
  59. amount_supplied=True,
  60. freight_amount="12.3",
  61. )
  62. assert plan.waybill_no is None
  63. assert plan.freight_amount == Decimal("12.30")
  64. assert plan.settle_status is None
  65. assert plan.clear_settle_time is True
  66. def test_other_mode_named_sf_still_requires_amount():
  67. plan = resolve_header_for_save(
  68. transport_mode="sf",
  69. existing_waybill=None,
  70. existing_amount=None,
  71. existing_settle_status=None,
  72. waybill_supplied=True,
  73. waybill_no="",
  74. amount_supplied=True,
  75. freight_amount="12.30",
  76. )
  77. assert plan.freight_amount == Decimal("12.30")
  78. assert plan.waybill_no is None
  79. assert plan.settle_status is None
  80. def test_non_sf_rejects_missing_amount_and_negative_amount():
  81. with pytest.raises(ValueError, match="请填写运费"):
  82. resolve_header_for_save(
  83. transport_mode="zxc",
  84. existing_waybill=None,
  85. existing_amount=None,
  86. existing_settle_status=None,
  87. waybill_supplied=True,
  88. waybill_no="",
  89. amount_supplied=True,
  90. freight_amount=None,
  91. )
  92. with pytest.raises(ValueError, match="运费不能为负数"):
  93. resolve_header_for_save(
  94. transport_mode="zxc",
  95. existing_waybill=None,
  96. existing_amount=None,
  97. existing_settle_status=None,
  98. waybill_supplied=True,
  99. waybill_no="",
  100. amount_supplied=True,
  101. freight_amount="-1",
  102. )
  103. def test_settled_header_cannot_change():
  104. with pytest.raises(ValueError, match="已结算的运费不能修改运输方式或顺丰单号"):
  105. resolve_header_for_save(
  106. transport_mode="sfkd",
  107. existing_waybill="SF001",
  108. existing_amount=None,
  109. existing_settle_status="yjs",
  110. waybill_supplied=True,
  111. waybill_no="SF002",
  112. amount_supplied=False,
  113. freight_amount=None,
  114. )
  115. def test_blank_settle_keeps_null_and_historical_amount():
  116. now = datetime(2026, 10, 21, 12, 0, 0)
  117. assert plan_settle(current_amount=None, submitted_amount=" ", now=now) == (
  118. None,
  119. "yjs",
  120. now,
  121. )
  122. assert plan_settle(current_amount=Decimal("10.00"), submitted_amount=None, now=now) == (
  123. Decimal("10.00"),
  124. "yjs",
  125. now,
  126. )
  127. amount, status, settle_time = plan_settle(
  128. current_amount=Decimal("10.00"), submitted_amount="20", now=now
  129. )
  130. assert amount == Decimal("20.00")
  131. assert status == "yjs"
  132. assert settle_time == now
  133. def test_batch_settle_rejects_amount_and_fill_keeps_only_monthly_rows():
  134. assert_settle_request(["a", "b"], None)
  135. with pytest.raises(ValueError, match="批量结算不能填写运费"):
  136. assert_settle_request(["a", "b"], "10")
  137. with pytest.raises(ValueError, match="请选择运费批次"):
  138. assert_settle_request([], None)
  139. assert plan_fill_amount(
  140. current_amount=None, settle_status="yjs", submitted_amount="6.5"
  141. ) == Decimal("6.50")
  142. with pytest.raises(ValueError, match="只有已结算且仍为月结的运费可以补填金额"):
  143. plan_fill_amount(
  144. current_amount=Decimal("1.00"), settle_status="yjs", submitted_amount="2"
  145. )
  146. def test_settled_batch_cannot_delete_or_change_samples():
  147. with pytest.raises(ValueError, match="已结算的运费不能删除"):
  148. assert_freight_mutable("yjs", "delete")
  149. with pytest.raises(ValueError, match="已结算的运费不能调整样本"):
  150. assert_freight_mutable("yjs", "items")
  151. assert_freight_mutable("wjs", "delete")
  152. assert freight_amount_label("sfkd", Decimal("8")) == "8.00"
  153. assert freight_amount_label("zxc", None) == ""
  154. def test_freight_model_allows_null_amount_and_settlement_columns():
  155. from app.models.models import BizSampleFreight
  156. cols = {column.name: column for column in BizSampleFreight.__table__.columns}
  157. assert cols["freight_amount"].nullable is True
  158. assert cols["waybill_no"].nullable is True
  159. assert cols["settle_status"].nullable is True
  160. assert cols["settle_time"].nullable is True
  161. def test_amount_serializer_keeps_null_instead_of_zero():
  162. from types import SimpleNamespace
  163. from app.crud.sample_freight import _row_to_dict
  164. from app.services.market_freight import _batch_dict
  165. obj = SimpleNamespace(
  166. id="f1",
  167. freight_no="YF1",
  168. transport_mode="sfkd",
  169. freight_amount=None,
  170. waybill_no=None,
  171. settle_status="wjs",
  172. settle_time=None,
  173. tenant_no="zkxw",
  174. order_num=0,
  175. remark="",
  176. create_by="u1",
  177. create_time=None,
  178. update_time=None,
  179. )
  180. admin = _row_to_dict(obj, transport_mode_label="顺丰快递", create_by_name="张三")
  181. market = _batch_dict(obj, 1, "顺丰快递", "张三")
  182. assert admin["freightAmount"] is None
  183. assert admin["waybillNo"] == ""
  184. assert admin["settleStatus"] == "wjs"
  185. assert admin["freightAmountLabel"] == "月结"
  186. assert market["freightAmount"] is None
  187. assert market["freightAmountLabel"] == "月结"
  188. def test_sample_card_payload_keeps_null_amount():
  189. from app.crud.sample_freight import freight_amount_payload
  190. monthly = freight_amount_payload(None, "sfkd", True)
  191. assert monthly["freightAmount"] is None
  192. assert monthly["freightAmountLabel"] == "月结"
  193. assert freight_amount_payload(None, "sfkd", False)["freightAmount"] is None
  194. assert freight_amount_payload(None, "zxc", True)["freightAmountLabel"] == ""
  195. numeric = freight_amount_payload(Decimal("88"), "zxc", True)
  196. assert numeric["freightAmount"] == "88.00"
  197. assert numeric["freightAmountLabel"] == "88.00"
  198. assert freight_amount_payload(Decimal("0"), "zxc", False)["freightAmount"] == 0.0
  199. def test_admin_and_market_bodies_accept_sf_without_amount():
  200. from app.api.sample_freight import SampleFreightCreateBody, SampleFreightUpdateBody
  201. from app.schemas.market_mobile import (
  202. MarketFreightCreateBody,
  203. MarketFreightUpdateBody,
  204. MarketProtocolSampleFreightDraftBody,
  205. )
  206. admin = SampleFreightCreateBody.model_validate({
  207. "transportMode": "sfkd",
  208. "waybillNo": "SF100",
  209. "items": [{"sampleSource": "cell_storage", "sampleId": "s1"}],
  210. })
  211. assert admin.freight_amount is None
  212. assert admin.waybill_no == "SF100"
  213. updated = SampleFreightUpdateBody.model_validate({"waybillNo": "SF101"})
  214. assert "waybill_no" in updated.model_fields_set
  215. market = MarketFreightCreateBody.model_validate({
  216. "transportMode": "sfkd",
  217. "waybillNo": "SF100",
  218. "items": [{"sampleSource": "cell_storage", "sampleId": "s1"}],
  219. })
  220. assert market.freight_amount is None
  221. assert market.waybill_no == "SF100"
  222. draft = MarketProtocolSampleFreightDraftBody.model_validate({
  223. "clientKey": "fd_1",
  224. "transportMode": "sfkd",
  225. "waybillNo": "SF100",
  226. })
  227. assert draft.waybill_no == "SF100"
  228. MarketFreightUpdateBody.model_validate({"waybillNo": "SF100"})
  229. def _protocol_freight_db():
  230. from sqlalchemy import create_engine
  231. from sqlalchemy.orm import sessionmaker
  232. from app.models.base import Base
  233. from app.models.models import (
  234. BizCellSampleType,
  235. BizCollectionProtocol,
  236. BizProtocolPartyCustomer,
  237. BizProtocolSample,
  238. BizSampleFreight,
  239. BizSampleFreightItem,
  240. SysDictData,
  241. )
  242. engine = create_engine("sqlite:///:memory:")
  243. Base.metadata.create_all(
  244. engine,
  245. tables=[
  246. BizSampleFreight.__table__,
  247. BizSampleFreightItem.__table__,
  248. BizProtocolSample.__table__,
  249. BizCollectionProtocol.__table__,
  250. BizCellSampleType.__table__,
  251. BizProtocolPartyCustomer.__table__,
  252. SysDictData.__table__,
  253. ],
  254. )
  255. return sessionmaker(bind=engine)()
  256. def _seed_settled_protocol_freight(db, sample_id="s-old"):
  257. from app.models.models import BizSampleFreight, BizSampleFreightItem
  258. now = datetime(2026, 10, 21, 9, 0, 0)
  259. freight = BizSampleFreight(
  260. id="f-yjs",
  261. freight_no="YF1",
  262. transport_mode="sfkd",
  263. freight_amount=None,
  264. waybill_no="SF001",
  265. settle_status="yjs",
  266. tenant_no="tenant-a",
  267. create_dept_id="",
  268. create_time=now,
  269. update_time=now,
  270. del_flag=0,
  271. )
  272. item = BizSampleFreightItem(
  273. id="fi-1",
  274. freight_id=freight.id,
  275. sample_source="cell_storage",
  276. sample_id=sample_id,
  277. sample_code="C1",
  278. protocol_id="p1",
  279. tenant_no="tenant-a",
  280. create_dept_id="",
  281. create_time=now,
  282. update_time=now,
  283. del_flag=0,
  284. )
  285. db.add_all([freight, item])
  286. db.commit()
  287. return freight, item
  288. def test_settled_protocol_save_without_freight_id_keeps_links():
  289. from types import SimpleNamespace
  290. from app.crud.sample_freight import apply_freight_on_protocol_save
  291. from app.models.models import BizSampleFreight, BizSampleFreightItem
  292. db = _protocol_freight_db()
  293. freight, item = _seed_settled_protocol_freight(db)
  294. try:
  295. with pytest.raises(ValueError, match="已结算的运费不能调整样本"):
  296. apply_freight_on_protocol_save(
  297. db,
  298. tenant_no="tenant-a",
  299. user_id="u1",
  300. sample_source="cell_storage",
  301. protocol_id="p1",
  302. saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")],
  303. input_samples=[{}],
  304. )
  305. db.expire_all()
  306. item = db.query(BizSampleFreightItem).filter_by(id=item.id).one()
  307. freight = db.query(BizSampleFreight).filter_by(id=freight.id).one()
  308. assert item.del_flag == 0
  309. assert freight.del_flag == 0
  310. finally:
  311. db.close()
  312. def test_settled_protocol_save_relinks_same_count_to_new_sample():
  313. from types import SimpleNamespace
  314. from app.crud.sample_freight import apply_freight_on_protocol_save
  315. from app.models.models import BizSampleFreight, BizSampleFreightItem
  316. db = _protocol_freight_db()
  317. freight, _item = _seed_settled_protocol_freight(db)
  318. try:
  319. apply_freight_on_protocol_save(
  320. db,
  321. tenant_no="tenant-a",
  322. user_id="u1",
  323. sample_source="cell_storage",
  324. protocol_id="p1",
  325. saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")],
  326. input_samples=[{"freightId": freight.id}],
  327. )
  328. db.expire_all()
  329. freight = db.query(BizSampleFreight).filter_by(id=freight.id).one()
  330. assert freight.del_flag == 0
  331. assert freight.settle_status == "yjs"
  332. live = (
  333. db.query(BizSampleFreightItem)
  334. .filter_by(freight_id=freight.id, sample_id="s-new", del_flag=0)
  335. .one()
  336. )
  337. assert live.protocol_id == "p1"
  338. finally:
  339. db.close()
  340. from sqlalchemy import create_engine
  341. from sqlalchemy.orm import sessionmaker
  342. from app.models.base import Base
  343. from app.models.models import BizSampleFreight, SysUser
  344. def _amount_equals(actual, expected: str) -> None:
  345. """SQLite 可能把 Numeric 读成 float,按数值比较,空金额必须保持 None。"""
  346. assert actual is not None
  347. assert Decimal(str(actual)) == Decimal(expected)
  348. @pytest.fixture
  349. def db():
  350. engine = create_engine("sqlite:///:memory:")
  351. Base.metadata.create_all(
  352. engine,
  353. tables=[SysUser.__table__, BizSampleFreight.__table__],
  354. )
  355. session = sessionmaker(bind=engine)()
  356. now = datetime(2026, 10, 21, 9, 0, 0)
  357. common = dict(
  358. tenant_no="tenant-a",
  359. create_dept_id="d1",
  360. create_time=now,
  361. update_time=now,
  362. del_flag=0,
  363. )
  364. session.add(
  365. SysUser(
  366. id="u1",
  367. user_no="u1",
  368. nick_name="张三",
  369. email="",
  370. phone_no="",
  371. password="",
  372. status="1",
  373. dept_id="d1",
  374. **common,
  375. )
  376. )
  377. session.add_all(
  378. [
  379. BizSampleFreight(
  380. id="sf-null",
  381. freight_no="YF1",
  382. transport_mode="sfkd",
  383. freight_amount=None,
  384. waybill_no="SF001",
  385. settle_status="wjs",
  386. create_by="u1",
  387. **common,
  388. ),
  389. BizSampleFreight(
  390. id="sf-old",
  391. freight_no="YF2",
  392. transport_mode="sfkd",
  393. freight_amount=Decimal("10.00"),
  394. waybill_no="",
  395. settle_status="wjs",
  396. create_by="u1",
  397. **common,
  398. ),
  399. BizSampleFreight(
  400. id="line",
  401. freight_no="YF3",
  402. transport_mode="zxc",
  403. freight_amount=Decimal("5.00"),
  404. create_by="u1",
  405. **common,
  406. ),
  407. BizSampleFreight(
  408. id="other-tenant",
  409. freight_no="YF4",
  410. transport_mode="sfkd",
  411. freight_amount=None,
  412. waybill_no="SF999",
  413. settle_status="wjs",
  414. create_by="u1",
  415. tenant_no="tenant-b",
  416. create_dept_id="d1",
  417. create_time=now,
  418. update_time=now,
  419. del_flag=0,
  420. ),
  421. ]
  422. )
  423. session.commit()
  424. try:
  425. yield session
  426. finally:
  427. session.close()
  428. USER = {
  429. "user_id": "u1",
  430. "tenant_no": "tenant-a",
  431. "role": "super_admin",
  432. "dept_id": "d1",
  433. }
  434. def test_list_returns_only_current_tenant_sf_rows(db):
  435. from app.crud.sf_express_settlement import list_sf_settlements
  436. rows, total = list_sf_settlements(
  437. db, tenant_no="tenant-a", user_info=USER, page=1, page_size=20
  438. )
  439. assert total == 2
  440. assert {row["id"] for row in rows} == {"sf-null", "sf-old"}
  441. assert all(row["transportMode"] == "sfkd" for row in rows)
  442. null_row = next(row for row in rows if row["id"] == "sf-null")
  443. assert null_row["freightAmountLabel"] == "月结"
  444. assert null_row["createByName"] == "张三"
  445. assert null_row["waybillNo"] == "SF001"
  446. def test_settle_blank_keeps_null_and_historical_amount(db):
  447. from app.crud.sf_express_settlement import settle_sf_freights
  448. settle_sf_freights(
  449. db,
  450. tenant_no="tenant-a",
  451. user_id="u1",
  452. user_info=USER,
  453. ids=["sf-null"],
  454. freight_amount=None,
  455. )
  456. settle_sf_freights(
  457. db,
  458. tenant_no="tenant-a",
  459. user_id="u1",
  460. user_info=USER,
  461. ids=["sf-old"],
  462. freight_amount="",
  463. )
  464. assert db.query(BizSampleFreight).filter_by(id="sf-null").one().freight_amount is None
  465. _amount_equals(
  466. db.query(BizSampleFreight).filter_by(id="sf-old").one().freight_amount, "10"
  467. )
  468. assert db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_status == "yjs"
  469. with pytest.raises(ValueError, match="该运费已结算"):
  470. settle_sf_freights(
  471. db,
  472. tenant_no="tenant-a",
  473. user_id="u1",
  474. user_info=USER,
  475. ids=["sf-null"],
  476. freight_amount=None,
  477. )
  478. def test_fill_amount_does_not_change_settle_time(db):
  479. from app.crud.sf_express_settlement import fill_sf_freight_amount, settle_sf_freights
  480. settle_sf_freights(
  481. db,
  482. tenant_no="tenant-a",
  483. user_id="u1",
  484. user_info=USER,
  485. ids=["sf-null"],
  486. freight_amount=None,
  487. )
  488. settled_at = db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_time
  489. fill_sf_freight_amount(
  490. db,
  491. tenant_no="tenant-a",
  492. user_id="u1",
  493. user_info=USER,
  494. freight_id="sf-null",
  495. freight_amount="15",
  496. )
  497. row = db.query(BizSampleFreight).filter_by(id="sf-null").one()
  498. _amount_equals(row.freight_amount, "15.00")
  499. assert row.settle_time == settled_at
  500. def test_export_empty_filter_raises_chinese_message(db):
  501. from app.crud.sf_express_settlement import list_sf_settlements_for_export
  502. with pytest.raises(ValueError, match="没有可导出的数据"):
  503. list_sf_settlements_for_export(
  504. db, tenant_no="tenant-a", user_info=USER, waybill_no="不存在"
  505. )
  506. def test_market_router_has_no_settlement_paths():
  507. from app.api.market_mobile import router as market_router
  508. from app.api.sample_freight import router as admin_router
  509. market_paths = [getattr(route, "path", "") for route in market_router.routes]
  510. admin_paths = [getattr(route, "path", "") for route in admin_router.routes]
  511. assert all("sf-settlement" not in path for path in market_paths)
  512. assert "/web/admin/sample-freights/sf-settlements" in admin_paths
  513. assert "/web/admin/sample-freights/sf-settlements/export" in admin_paths