test_pl_contract_plan_qty.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. """合同产品运输安排数量汇总单元测试。"""
  2. from types import SimpleNamespace
  3. from collections import defaultdict
  4. from app.services.pl_contract_plan_qty import (
  5. _bucket_for_plan,
  6. accumulate_stop_products,
  7. attach_plan_qty_fields_to_products,
  8. match_contract_product_for_key,
  9. merge_product_qty_maps,
  10. normalize_package_spec,
  11. product_package_key,
  12. resolve_linked_plan_scopes,
  13. sum_qtys_for_scope,
  14. )
  15. def test_bucket_archived():
  16. assert (
  17. _bucket_for_plan(
  18. archive_status="archived",
  19. follow_status="arranged",
  20. transport_status="arranged",
  21. )
  22. == "archived"
  23. )
  24. def test_bucket_arranged_unarchived_includes_draft_archive():
  25. assert (
  26. _bucket_for_plan(
  27. archive_status="draft",
  28. follow_status="arranged",
  29. transport_status="arranged",
  30. )
  31. == "arranged_unarchived"
  32. )
  33. def test_bucket_skips_pending_and_cancelled():
  34. assert (
  35. _bucket_for_plan(
  36. archive_status="unarchived",
  37. follow_status="pending_arrange",
  38. transport_status="pending",
  39. )
  40. is None
  41. )
  42. assert (
  43. _bucket_for_plan(
  44. archive_status="unarchived",
  45. follow_status="cancelled",
  46. transport_status="arranged",
  47. )
  48. is None
  49. )
  50. def test_normalize_package_spec():
  51. from app.services.pl_contract_plan_qty import normalize_package_spec
  52. assert normalize_package_spec(None) == ""
  53. assert normalize_package_spec(" ") == ""
  54. assert normalize_package_spec(" 槽罐车 ") == "槽罐车"
  55. assert normalize_package_spec("25kg/桶") != normalize_package_spec("25Kg/袋")
  56. def test_accumulate_splits_same_product_different_package():
  57. acc = defaultdict(float)
  58. accumulate_stop_products(
  59. acc,
  60. [
  61. SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", plan_qty=15),
  62. SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", plan_qty=700),
  63. SimpleNamespace(product_id="SXWL007344", package_spec=" 槽罐车 ", plan_qty=10),
  64. ],
  65. )
  66. k_bucket = ("SXWL007344", "25kg/桶")
  67. k_tank = ("SXWL007344", "槽罐车")
  68. assert acc[k_bucket] == 15.0
  69. assert acc[k_tank] == 710.0
  70. def test_merge_product_qty_maps_composite_keys():
  71. out = merge_product_qty_maps(
  72. archived={("p1", "A"): 10.0},
  73. arranged_unarchived={("p1", "B"): 20.0, ("p1", "A"): 5.0},
  74. )
  75. assert out[("p1", "A")]["arrangedTotalQty"] == 15.0
  76. assert out[("p1", "B")]["arrangedTotalQty"] == 20.0
  77. def test_attach_plan_qty_fields_matches_package():
  78. qty_map = {
  79. ("pid1", "25kg/桶"): {
  80. "archivedQty": 1.0,
  81. "arrangedUnarchivedQty": 2.0,
  82. "arrangedTotalQty": 3.0,
  83. },
  84. ("pid1", "槽罐车"): {
  85. "archivedQty": 10.0,
  86. "arrangedUnarchivedQty": 20.0,
  87. "arrangedTotalQty": 30.0,
  88. },
  89. }
  90. products = [
  91. {"productId": "pid1", "packageSpec": "25kg/桶"},
  92. {"productId": "pid1", "packageSpec": "槽罐车"},
  93. ]
  94. attach_plan_qty_fields_to_products(products, qty_map)
  95. assert products[0]["arrangedTotalQty"] == 3.0
  96. assert products[1]["arrangedTotalQty"] == 30.0
  97. def test_merge_product_qty_maps_totals():
  98. out = merge_product_qty_maps(
  99. archived={("p1", ""): 120.0, ("p2", ""): 50.0},
  100. arranged_unarchived={("p1", ""): 80.0, ("p3", ""): 100.0},
  101. )
  102. assert out[("p1", "")]["archivedQty"] == 120.0
  103. assert out[("p1", "")]["arrangedUnarchivedQty"] == 80.0
  104. assert out[("p1", "")]["arrangedTotalQty"] == 200.0
  105. assert out[("p2", "")]["arrangedTotalQty"] == 50.0
  106. assert out[("p3", "")]["arrangedTotalQty"] == 100.0
  107. def test_sum_qtys_explicit_delivery_stop_only():
  108. load_products = {
  109. "L1": [SimpleNamespace(product_id="p1", plan_qty=10)],
  110. }
  111. delivery_products = {
  112. "D1": [SimpleNamespace(product_id="p1", plan_qty=40)],
  113. "D2": [SimpleNamespace(product_id="p1", plan_qty=50)],
  114. }
  115. qtys = sum_qtys_for_scope(
  116. load_products_by_load=load_products,
  117. delivery_products_by_delivery=delivery_products,
  118. all_load_ids={"L1"},
  119. all_delivery_ids={"D1", "D2"},
  120. scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False},
  121. )
  122. assert qtys == {("p1", ""): 40.0}
  123. def test_legacy_use_all_stops_prefers_delivery():
  124. load_products = {
  125. "L1": [SimpleNamespace(product_id="p1", plan_qty=99)],
  126. }
  127. delivery_products = {
  128. "D1": [SimpleNamespace(product_id="p1", plan_qty=40)],
  129. }
  130. qtys = sum_qtys_for_scope(
  131. load_products_by_load=load_products,
  132. delivery_products_by_delivery=delivery_products,
  133. all_load_ids={"L1"},
  134. all_delivery_ids={"D1"},
  135. scope={"load_ids": set(), "delivery_ids": set(), "use_all_stops": True},
  136. )
  137. assert qtys == {("p1", ""): 40.0}
  138. def test_resolve_scopes_marks_legacy():
  139. links = [
  140. SimpleNamespace(plan_id="A", side="delivery", stop_id="D1"),
  141. ]
  142. scopes = resolve_linked_plan_scopes(links, ["B"])
  143. assert scopes["A"]["delivery_ids"] == {"D1"}
  144. assert scopes["A"]["has_explicit"] is True
  145. assert scopes["B"]["use_all_stops"] is True
  146. def test_accumulate_stop_products():
  147. acc = defaultdict(float)
  148. accumulate_stop_products(
  149. acc,
  150. [
  151. SimpleNamespace(product_id="p1", plan_qty=DecimalLike(12.5)),
  152. SimpleNamespace(product_id="p1", plan_qty=7.5),
  153. SimpleNamespace(product_id="", plan_qty=9),
  154. ],
  155. )
  156. assert acc[("p1", "")] == 20.0
  157. def test_accumulate_archive_actual_load_uses_inbound_qty():
  158. acc = defaultdict(float)
  159. accumulate_stop_products(
  160. acc,
  161. [
  162. SimpleNamespace(product_id="p1", plan_qty=100, inbound_qty=88),
  163. SimpleNamespace(product_id="p1", plan_qty=50, inbound_qty=None),
  164. ],
  165. stop_kind="load",
  166. qty_mode="archive_actual",
  167. )
  168. # 88 + 回退 plan_qty 50
  169. assert acc[("p1", "")] == 138.0
  170. def test_accumulate_archive_actual_delivery_uses_outbound_qty():
  171. acc = defaultdict(float)
  172. accumulate_stop_products(
  173. acc,
  174. [SimpleNamespace(product_id="p1", plan_qty=100, outbound_qty=66)],
  175. stop_kind="delivery",
  176. qty_mode="archive_actual",
  177. )
  178. assert acc[("p1", "")] == 66.0
  179. def test_accumulate_archive_actual_zero_does_not_fallback():
  180. acc = defaultdict(float)
  181. accumulate_stop_products(
  182. acc,
  183. [SimpleNamespace(product_id="p1", plan_qty=100, inbound_qty=0)],
  184. stop_kind="load",
  185. qty_mode="archive_actual",
  186. )
  187. assert acc[("p1", "")] == 0.0
  188. def test_sum_qtys_archive_actual_mode_on_scoped_stops():
  189. load_products = {
  190. "L1": [SimpleNamespace(product_id="p1", plan_qty=10, inbound_qty=7)],
  191. }
  192. delivery_products = {
  193. "D1": [SimpleNamespace(product_id="p1", plan_qty=40, outbound_qty=33)],
  194. }
  195. qtys = sum_qtys_for_scope(
  196. load_products_by_load=load_products,
  197. delivery_products_by_delivery=delivery_products,
  198. all_load_ids={"L1"},
  199. all_delivery_ids={"D1"},
  200. scope={"load_ids": {"L1"}, "delivery_ids": {"D1"}, "use_all_stops": False},
  201. qty_mode="archive_actual",
  202. )
  203. assert qtys == {("p1", ""): 40.0} # 7 + 33
  204. def test_sum_qtys_default_mode_ignores_actual_fields():
  205. load_products = {
  206. "L1": [SimpleNamespace(product_id="p1", plan_qty=10, inbound_qty=7)],
  207. }
  208. delivery_products = {
  209. "D1": [SimpleNamespace(product_id="p1", plan_qty=40, outbound_qty=33)],
  210. }
  211. qtys = sum_qtys_for_scope(
  212. load_products_by_load=load_products,
  213. delivery_products_by_delivery=delivery_products,
  214. all_load_ids={"L1"},
  215. all_delivery_ids={"D1"},
  216. scope={"load_ids": set(), "delivery_ids": {"D1"}, "use_all_stops": False},
  217. )
  218. assert qtys == {("p1", ""): 40.0}
  219. def test_match_contract_product_for_key_picks_package():
  220. products = [
  221. SimpleNamespace(product_id="SXWL007344", package_spec="25kg/桶", quantity=15),
  222. SimpleNamespace(product_id="SXWL007344", package_spec="槽罐车", quantity=700),
  223. ]
  224. bucket = match_contract_product_for_key(
  225. products, product_package_key("SXWL007344", "25kg/桶")
  226. )
  227. tank = match_contract_product_for_key(
  228. products, product_package_key("SXWL007344", " 槽罐车 ")
  229. )
  230. assert bucket is not None and float(bucket.quantity) == 15.0
  231. assert tank is not None and float(tank.quantity) == 700.0
  232. def test_match_contract_product_for_key_sums_duplicate_keys():
  233. products = [
  234. SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=100),
  235. SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=50),
  236. ]
  237. row = match_contract_product_for_key(products, ("p1", "槽罐车"))
  238. assert row is not None and float(row.quantity) == 150.0
  239. def test_match_contract_product_for_key_missing():
  240. products = [SimpleNamespace(product_id="p1", package_spec="槽罐车", quantity=10)]
  241. assert match_contract_product_for_key(products, ("p1", "25kg/桶")) is None
  242. def test_match_shared_pool_line_json_package_spec():
  243. from app.utils.crm_package_spec import encode_package_specs
  244. products = [
  245. SimpleNamespace(
  246. id="L1",
  247. product_id="SXWL007344",
  248. package_spec=encode_package_specs(["25kg/桶", "槽罐车"]),
  249. quantity=715,
  250. )
  251. ]
  252. bucket = match_contract_product_for_key(
  253. products, product_package_key("SXWL007344", "25kg/桶")
  254. )
  255. tank = match_contract_product_for_key(
  256. products, product_package_key("SXWL007344", "槽罐车")
  257. )
  258. assert bucket is tank is not None
  259. assert float(bucket.quantity) == 715.0
  260. def test_attach_plan_qty_shared_pool_breakdown():
  261. from app.utils.crm_package_spec import encode_package_specs
  262. products = [
  263. {
  264. "id": "L1",
  265. "productId": "pid1",
  266. "quantity": 715,
  267. "packageSpec": encode_package_specs(["25kg/桶", "槽罐车"]),
  268. }
  269. ]
  270. qty_map = {
  271. ("pid1", "25kg/桶"): {
  272. "archivedQty": 0.0,
  273. "arrangedUnarchivedQty": 10.0,
  274. "arrangedTotalQty": 10.0,
  275. },
  276. ("pid1", "槽罐车"): {
  277. "archivedQty": 0.0,
  278. "arrangedUnarchivedQty": 10.0,
  279. "arrangedTotalQty": 10.0,
  280. },
  281. }
  282. attach_plan_qty_fields_to_products(products, qty_map)
  283. assert products[0]["arrangedTotalQty"] == 20.0
  284. assert products[0]["remainingQty"] == 695.0
  285. assert products[0]["packageSpecs"] == ["25kg/桶", "槽罐车"]
  286. assert len(products[0]["packageQtyBreakdown"]) == 2
  287. assert products[0]["packageQtyBreakdown"][0]["arrangedTotalQty"] == 10.0
  288. assert products[0]["packageQtyBreakdown"][1]["arrangedTotalQty"] == 10.0
  289. def test_sum_qty_stats_for_keys_shared_pool():
  290. from app.services.pl_contract_plan_qty import sum_qty_stats_for_keys
  291. qty_map = {
  292. ("pid1", "25kg/桶"): {
  293. "archivedQty": 0.0,
  294. "arrangedUnarchivedQty": 10.0,
  295. "arrangedTotalQty": 10.0,
  296. },
  297. ("pid1", "槽罐车"): {
  298. "archivedQty": 0.0,
  299. "arrangedUnarchivedQty": 10.0,
  300. "arrangedTotalQty": 10.0,
  301. },
  302. }
  303. stats = sum_qty_stats_for_keys(
  304. qty_map, [("pid1", "25kg/桶"), ("pid1", "槽罐车")]
  305. )
  306. assert stats["arrangedTotalQty"] == 20.0
  307. class DecimalLike:
  308. def __init__(self, v):
  309. self.v = v
  310. def __float__(self):
  311. return float(self.v)
  312. def test_accumulate_prefers_contract_product_id():
  313. acc = defaultdict(float)
  314. accumulate_stop_products(
  315. acc,
  316. [
  317. SimpleNamespace(
  318. product_id="p1",
  319. package_spec="槽罐车",
  320. plan_qty=10,
  321. contract_product_id="line-a",
  322. ),
  323. SimpleNamespace(
  324. product_id="p1",
  325. package_spec="槽罐车",
  326. plan_qty=20,
  327. contract_product_id="line-b",
  328. ),
  329. ],
  330. )
  331. from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
  332. assert acc[(LINE_OCCUPANCY_PREFIX, "line-a")] == 10.0
  333. assert acc[(LINE_OCCUPANCY_PREFIX, "line-b")] == 20.0
  334. assert ("p1", "槽罐车") not in acc
  335. def test_attach_plan_qty_independent_duplicate_lines():
  336. from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
  337. products = [
  338. {"id": "line-a", "productId": "pid1", "packageSpec": "槽罐车", "quantity": 50},
  339. {"id": "line-b", "productId": "pid1", "packageSpec": "槽罐车", "quantity": 40},
  340. ]
  341. qty_map = {
  342. (LINE_OCCUPANCY_PREFIX, "line-a"): {
  343. "archivedQty": 0.0,
  344. "arrangedUnarchivedQty": 10.0,
  345. "arrangedTotalQty": 10.0,
  346. }
  347. }
  348. attach_plan_qty_fields_to_products(products, qty_map)
  349. assert products[0]["arrangedTotalQty"] == 10.0
  350. assert products[0]["remainingQty"] == 40.0
  351. assert products[1]["arrangedTotalQty"] == 0.0
  352. assert products[1]["remainingQty"] == 40.0
  353. def test_attach_plan_qty_line_bound_multi_package_breakdown():
  354. """绑定合同行后数量记在 __line__;多包装拆开展示须带上对应包装的归档量。"""
  355. from app.utils.crm_package_spec import encode_package_specs
  356. from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX, package_display_key
  357. acc = defaultdict(float)
  358. accumulate_stop_products(
  359. acc,
  360. [
  361. SimpleNamespace(
  362. product_id="pid1",
  363. package_spec="20kg/袋",
  364. plan_qty=0.55,
  365. inbound_qty=0.55,
  366. contract_product_id="line-2",
  367. )
  368. ],
  369. stop_kind="load",
  370. qty_mode="archive_actual",
  371. )
  372. assert acc[(LINE_OCCUPANCY_PREFIX, "line-2")] == 0.55
  373. assert ("pid1", "20kg/袋") not in acc
  374. dkey = package_display_key("line-2", "20kg/袋")
  375. assert dkey in acc and acc[dkey] == 0.55
  376. products = [
  377. {
  378. "id": "line-2",
  379. "productId": "pid1",
  380. "quantity": 1,
  381. "packageSpec": encode_package_specs(["20kg/袋", "50Kg/桶", "1.2吨/桶"]),
  382. }
  383. ]
  384. qty_map = {
  385. (LINE_OCCUPANCY_PREFIX, "line-2"): {
  386. "archivedQty": 0.55,
  387. "arrangedUnarchivedQty": 0.0,
  388. "arrangedTotalQty": 0.55,
  389. },
  390. dkey: {
  391. "archivedQty": 0.55,
  392. "arrangedUnarchivedQty": 0.0,
  393. "arrangedTotalQty": 0.55,
  394. },
  395. }
  396. attach_plan_qty_fields_to_products(products, qty_map)
  397. assert products[0]["arrangedTotalQty"] == 0.55
  398. assert products[0]["remainingQty"] == 0.45
  399. by_spec = {x["packageSpec"]: x for x in products[0]["packageQtyBreakdown"]}
  400. assert by_spec["20kg/袋"]["archivedQty"] == 0.55
  401. assert by_spec["50Kg/桶"]["archivedQty"] == 0.0
  402. assert by_spec["1.2吨/桶"]["archivedQty"] == 0.0
  403. def test_package_display_qty_isolated_per_contract_line():
  404. from app.utils.crm_package_spec import encode_package_specs
  405. from app.services.pl_contract_plan_qty import LINE_OCCUPANCY_PREFIX
  406. acc = defaultdict(float)
  407. accumulate_stop_products(
  408. acc,
  409. [
  410. SimpleNamespace(
  411. product_id="pid1",
  412. package_spec="20kg/袋",
  413. plan_qty=5.1,
  414. inbound_qty=5.1,
  415. contract_product_id="line-1",
  416. ),
  417. SimpleNamespace(
  418. product_id="pid1",
  419. package_spec="20kg/袋",
  420. plan_qty=0.55,
  421. inbound_qty=0.55,
  422. contract_product_id="line-2",
  423. ),
  424. ],
  425. stop_kind="load",
  426. qty_mode="archive_actual",
  427. )
  428. qty_map = merge_product_qty_maps(archived=dict(acc), arranged_unarchived={})
  429. products = [
  430. {
  431. "id": "line-1",
  432. "productId": "pid1",
  433. "quantity": 11,
  434. "packageSpec": encode_package_specs(["20kg/袋"]),
  435. },
  436. {
  437. "id": "line-2",
  438. "productId": "pid1",
  439. "quantity": 1,
  440. "packageSpec": encode_package_specs(["20kg/袋", "50Kg/桶", "1.2吨/桶"]),
  441. },
  442. ]
  443. attach_plan_qty_fields_to_products(products, qty_map)
  444. assert products[0]["archivedQty"] == 5.1
  445. assert products[0]["remainingQty"] == 5.9
  446. assert products[0]["packageQtyBreakdown"][0]["archivedQty"] == 5.1
  447. assert products[1]["archivedQty"] == 0.55
  448. assert products[1]["remainingQty"] == 0.45
  449. by_spec = {x["packageSpec"]: x for x in products[1]["packageQtyBreakdown"]}
  450. assert by_spec["20kg/袋"]["archivedQty"] == 0.55
  451. assert (LINE_OCCUPANCY_PREFIX, "line-1") in acc
  452. def test_occupancy_keys_do_not_share_package_across_duplicate_lines():
  453. from app.services.pl_contract_plan_qty import occupancy_keys_for_contract_line
  454. lines = [
  455. SimpleNamespace(id="a", product_id="p1", package_spec="槽罐车"),
  456. SimpleNamespace(id="b", product_id="p1", package_spec="槽罐车"),
  457. ]
  458. keys_a = occupancy_keys_for_contract_line(lines[0], lines)
  459. keys_b = occupancy_keys_for_contract_line(lines[1], lines)
  460. assert ("__line__", "a") in keys_a
  461. assert ("p1", "槽罐车") in keys_a
  462. assert ("__line__", "b") in keys_b
  463. assert ("p1", "槽罐车") not in keys_b