test_pl_contract_plan_qty.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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. class DecimalLike:
  243. def __init__(self, v):
  244. self.v = v
  245. def __float__(self):
  246. return float(self.v)