test_pl_plan_archive_export.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. from decimal import Decimal
  2. from unittest.mock import MagicMock, patch
  3. from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead
  4. from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES
  5. from app.services.pl_plan_archive_export import (
  6. ARCHIVE_MERGED_SHEET_TITLE,
  7. _plan_common_export_fields,
  8. append_archive_list_merge_sheet,
  9. build_plan_archive_export_rows,
  10. prepare_archive_list_display_rows,
  11. resolve_archive_list_io_column,
  12. )
  13. from app.utils.excel_export import ExportColumn
  14. def _stop_product(**kwargs) -> PlanStopProductRead:
  15. defaults = {
  16. "id": "sp1",
  17. "planId": "p1",
  18. "tenantNo": "t1",
  19. "productId": "prod1",
  20. "productNo": "P001",
  21. "productName": "甲醇",
  22. "model": "99%",
  23. "unit": "吨",
  24. "packageSpec": "散装",
  25. "planQty": Decimal("10"),
  26. "createTime": "2026-08-27T00:00:00",
  27. "updateTime": "2026-08-27T00:00:00",
  28. }
  29. defaults.update(kwargs)
  30. return PlanStopProductRead(**defaults)
  31. def test_resolve_archive_list_io_column():
  32. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  33. assert resolve_archive_list_io_column("load", internal) == "outbound"
  34. assert resolve_archive_list_io_column("load", "外部客户") == "inbound"
  35. assert resolve_archive_list_io_column("delivery", internal) == "inbound"
  36. assert resolve_archive_list_io_column("delivery", "外部客户") == "outbound"
  37. # 配对宽表 helper(_append_paired_product_rows / _append_stop_product_rows)单测已删除:
  38. # 归档导出改为段次×产品多行,不再断言同排 loadPlanQty+deliveryPlanQty / deliveryStopKind。
  39. # 品牌别名与内部客户合同占位改由 build_plan_archive_export_rows 覆盖。
  40. def _bare_plan(**overrides):
  41. plan = MagicMock()
  42. plan.id = "p1"
  43. plan.plan_no = "PL001"
  44. plan.contract_no = "C1"
  45. plan.plan_date = "2026-08-27"
  46. plan.archive_status = "archived"
  47. plan.follow_status = "arranged"
  48. plan.plan_type = "direct"
  49. plan.license_plate = "皖A12345"
  50. plan.trailer_no = None
  51. plan.erp_entered = 0
  52. plan.freight_amount = None
  53. plan.remark = ""
  54. plan.transport_id = None
  55. plan.customer_name = "外部客户"
  56. plan.supplier_name = None
  57. plan.destination_name = None
  58. plan.delivery_time = None
  59. for key, value in overrides.items():
  60. setattr(plan, key, value)
  61. return plan
  62. def _archive_rows(
  63. plan,
  64. load,
  65. deliveries,
  66. *,
  67. stop_kind=None,
  68. legacy_products=None,
  69. alias_maps=None,
  70. ):
  71. if legacy_products is None:
  72. legacy_products = {}
  73. if alias_maps is None:
  74. alias_maps = ({}, {})
  75. with patch(
  76. "app.services.pl_plan_archive_export.load_plan_stops_grouped",
  77. return_value={plan.id: (load, deliveries)},
  78. ), patch(
  79. "app.services.pl_plan_archive_export._load_legacy_products_by_plan",
  80. return_value=legacy_products,
  81. ), patch(
  82. "app.services.pl_plan_archive_export._load_contract_nos_by_plan",
  83. return_value={plan.id: []},
  84. ), patch(
  85. "app.services.pl_plan_archive_export.load_crm_product_alias_maps",
  86. return_value=alias_maps,
  87. ):
  88. return build_plan_archive_export_rows(
  89. MagicMock(), "t1", [plan], {}, stop_kind=stop_kind
  90. )
  91. def test_build_plan_archive_export_rows_segment_rows_and_single_side():
  92. """默认按段次×产品展开:1 个装货产品 + 1 个送货产品 = 2 行;单侧筛选只留对应段。"""
  93. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  94. load_product = _stop_product(
  95. planQty=Decimal("10"),
  96. inboundQty=Decimal("10"),
  97. inboundPrice=Decimal("100"),
  98. )
  99. delivery_product = _stop_product(
  100. id="dp1",
  101. planQty=Decimal("12"),
  102. outboundQty=Decimal("9"),
  103. outboundPrice=Decimal("110"),
  104. outboundAmount=Decimal("990"),
  105. )
  106. load = PlanLoadRead(
  107. id="load1",
  108. planId="p1",
  109. tenantNo="t1",
  110. customerName=internal,
  111. address="装货地址",
  112. loadTime="2026-08-27T08:00:00",
  113. products=[load_product],
  114. createTime="2026-08-27T00:00:00",
  115. updateTime="2026-08-27T00:00:00",
  116. )
  117. delivery = PlanDeliveryRead(
  118. id="del1",
  119. planId="p1",
  120. tenantNo="t1",
  121. seq=2,
  122. customerName="外部客户",
  123. address="送货地址",
  124. deliveryTime="2026-08-28T10:00:00",
  125. products=[delivery_product],
  126. createTime="2026-08-27T00:00:00",
  127. updateTime="2026-08-27T00:00:00",
  128. )
  129. plan = _bare_plan()
  130. rows = _archive_rows(plan, load, [delivery], stop_kind=None)
  131. load_only = _archive_rows(plan, load, [delivery], stop_kind="load")
  132. delivery_only = _archive_rows(plan, load, [delivery], stop_kind="delivery")
  133. assert len(rows) == 2
  134. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #2"]
  135. assert rows[0]["productName"] == "甲醇"
  136. assert rows[1]["productName"] == "甲醇"
  137. assert rows[0]["loadCustomer"] == internal
  138. assert rows[0]["deliveryCustomer"] == ""
  139. assert rows[0]["loadAddress"] == "装货地址"
  140. assert rows[0]["loadTime"] == "2026-08-27"
  141. assert rows[0]["deliveryAddress"] == ""
  142. assert rows[0]["deliveryTime"] == ""
  143. assert rows[0]["planQty"] == Decimal("10")
  144. assert rows[0]["actualQty"] == Decimal("10")
  145. assert rows[0]["unitPrice"] == Decimal("100")
  146. assert rows[0]["amount"] == Decimal("1000")
  147. assert rows[1]["loadCustomer"] == ""
  148. assert rows[1]["deliveryCustomer"] == "外部客户"
  149. assert rows[1]["loadAddress"] == ""
  150. assert rows[1]["loadTime"] == ""
  151. assert rows[1]["deliveryAddress"] == "送货地址"
  152. assert rows[1]["deliveryTime"] == "2026-08-28"
  153. assert rows[1]["planQty"] == Decimal("12")
  154. assert rows[1]["actualQty"] == Decimal("9")
  155. assert rows[1]["unitPrice"] == Decimal("110")
  156. assert rows[1]["amount"] == Decimal("990")
  157. assert rows[0].get("loadPlanQty") is None
  158. assert rows[0].get("deliveryPlanQty") is None
  159. assert rows[1].get("loadPlanQty") is None
  160. assert rows[1].get("deliveryPlanQty") is None
  161. assert not rows[0].get("deliveryStopKind")
  162. assert not rows[1].get("deliveryStopKind")
  163. assert len(load_only) == 1
  164. assert load_only[0]["stopSegment"] == "装货"
  165. assert load_only[0]["loadCustomer"] == internal
  166. assert load_only[0]["deliveryCustomer"] == ""
  167. assert load_only[0]["planQty"] == Decimal("10")
  168. assert load_only[0]["actualQty"] == Decimal("10")
  169. assert load_only[0]["unitPrice"] == Decimal("100")
  170. assert load_only[0]["amount"] == Decimal("1000")
  171. assert load_only[0].get("loadPlanQty") is None
  172. assert len(delivery_only) == 1
  173. assert delivery_only[0]["stopSegment"] == "送货 #2"
  174. assert delivery_only[0]["deliveryCustomer"] == "外部客户"
  175. assert delivery_only[0]["loadCustomer"] == ""
  176. assert delivery_only[0]["planQty"] == Decimal("12")
  177. assert delivery_only[0]["actualQty"] == Decimal("9")
  178. assert delivery_only[0]["unitPrice"] == Decimal("110")
  179. assert delivery_only[0]["amount"] == Decimal("990")
  180. assert delivery_only[0].get("deliveryPlanQty") is None
  181. class _FakePlan:
  182. id = "p1"
  183. plan_no = "PL001"
  184. contract_no = "SX/X-JT20260827001"
  185. plan_date = "2026-08-27"
  186. archive_status = "archived"
  187. follow_status = "arranged"
  188. plan_type = "direct"
  189. license_plate = "皖A12345"
  190. trailer_no = "挂001"
  191. erp_entered = 1
  192. freight_amount = Decimal("500")
  193. remark = "测试"
  194. def test_plan_common_export_fields():
  195. fields = _plan_common_export_fields(_FakePlan(), None)
  196. assert fields["planId"] == "p1"
  197. assert fields["planNo"] == "PL001"
  198. assert fields["contractNo"] == "SX/X-JT20260827001"
  199. assert fields["archiveStatus"] == "已归档"
  200. assert fields["followStatus"] == "已安排"
  201. assert fields["planType"] == "直销单"
  202. assert fields["licensePlate"] == "皖A12345挂001"
  203. assert fields["erpEntered"] == "是"
  204. def test_build_plan_archive_export_rows_includes_brand_and_alias():
  205. plan_product = MagicMock()
  206. plan_product.id = "pp1"
  207. plan_product.product_id = "prod1"
  208. plan_product.product_name = "甲醇别名"
  209. plan_product.brand_mode = "required"
  210. plan_product.brand_name = "君正"
  211. load_product = _stop_product(planProductId="pp1", productName="甲醇别名")
  212. delivery_product = _stop_product(
  213. id="dp1",
  214. planProductId="pp1",
  215. productName="甲醇别名",
  216. outboundQty=Decimal("9"),
  217. outboundPrice=Decimal("110"),
  218. )
  219. load = PlanLoadRead(
  220. id="load1",
  221. planId="p1",
  222. tenantNo="t1",
  223. customerName="装货客户",
  224. products=[load_product],
  225. createTime="2026-08-27T00:00:00",
  226. updateTime="2026-08-27T00:00:00",
  227. )
  228. delivery = PlanDeliveryRead(
  229. id="del1",
  230. planId="p1",
  231. tenantNo="t1",
  232. seq=1,
  233. customerName="送货客户",
  234. products=[delivery_product],
  235. createTime="2026-08-27T00:00:00",
  236. updateTime="2026-08-27T00:00:00",
  237. )
  238. rows = _archive_rows(
  239. _bare_plan(),
  240. load,
  241. [delivery],
  242. legacy_products={"p1": [plan_product]},
  243. alias_maps=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}),
  244. )
  245. assert len(rows) == 2
  246. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"]
  247. assert rows[0]["loadCustomer"] == "装货客户"
  248. assert rows[0]["deliveryCustomer"] == ""
  249. assert rows[1]["loadCustomer"] == ""
  250. assert rows[1]["deliveryCustomer"] == "送货客户"
  251. for row in rows:
  252. assert row["productName"] == "甲醇"
  253. assert row["brandName"] == "君正"
  254. assert row["aliasName"] == "甲醇别名"
  255. def test_plan_common_export_fields_merges_link_contract_nos():
  256. fields = _plan_common_export_fields(
  257. _FakePlan(),
  258. None,
  259. contract_nos=["SX/X-JT20260827001", "SX/X-JT20260827002"],
  260. )
  261. assert fields["contractNo"] == "SX/X-JT20260827001、SX/X-JT20260827002"
  262. def test_build_plan_archive_export_rows_internal_customer_contract_per_segment():
  263. """内部客户缺合同:装货行合同为出库,送货行为入库,不再拼在同一行。"""
  264. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  265. load_product = _stop_product(id="lp1", inboundQty=Decimal("10"))
  266. delivery_product = _stop_product(id="dp1", outboundQty=Decimal("10"))
  267. load = PlanLoadRead(
  268. id="load1",
  269. planId="p1",
  270. tenantNo="t1",
  271. customerName=internal,
  272. products=[load_product],
  273. contracts=[],
  274. createTime="2026-08-27T00:00:00",
  275. updateTime="2026-08-27T00:00:00",
  276. )
  277. delivery = PlanDeliveryRead(
  278. id="del1",
  279. planId="p1",
  280. tenantNo="t1",
  281. seq=1,
  282. customerName=internal,
  283. products=[delivery_product],
  284. contracts=[],
  285. createTime="2026-08-27T00:00:00",
  286. updateTime="2026-08-27T00:00:00",
  287. )
  288. rows = _archive_rows(_bare_plan(contract_no=""), load, [delivery])
  289. assert len(rows) == 2
  290. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"]
  291. assert rows[0]["contractNo"] == "出库"
  292. assert rows[0]["loadCustomer"] == internal
  293. assert rows[0]["deliveryCustomer"] == ""
  294. assert rows[1]["contractNo"] == "入库"
  295. assert rows[1]["deliveryCustomer"] == internal
  296. assert rows[1]["loadCustomer"] == ""
  297. class _CellResolver:
  298. def row_to_cells(self, row, columns):
  299. return ["" if row.get(column.field) is None else row.get(column.field) for column in columns]
  300. _MERGE_COLUMNS = [
  301. ExportColumn(title="序号", field="rowNo"),
  302. ExportColumn(title="计划编号", field="planNo"),
  303. ExportColumn(title="装货客户", field="loadCustomer"),
  304. ExportColumn(title="送货客户", field="deliveryCustomer"),
  305. ExportColumn(title="合同编号", field="contractNo"),
  306. ExportColumn(title="产品名称", field="productName"),
  307. ExportColumn(title="计划量", field="planQty"),
  308. ]
  309. def _merged_ranges(sheet):
  310. return {str(item) for item in sheet.merged_cells.ranges}
  311. def test_prepare_single_delivery_puts_delivery_customer_on_first_row():
  312. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  313. load = PlanLoadRead(
  314. id="load1",
  315. planId="p1",
  316. tenantNo="t1",
  317. customerName="合肥玉坤",
  318. address="装货地址",
  319. loadTime="2026-10-09T00:00:00",
  320. products=[
  321. _stop_product(id="lp1", productName="片碱", planQty=Decimal("0.9")),
  322. _stop_product(id="lp2", productName="熟石灰", planQty=Decimal("1.75")),
  323. ],
  324. contracts=[],
  325. createTime="2026-10-09T00:00:00",
  326. updateTime="2026-10-09T00:00:00",
  327. )
  328. delivery = PlanDeliveryRead(
  329. id="del1",
  330. planId="p1",
  331. tenantNo="t1",
  332. seq=1,
  333. customerName="凤阳京环",
  334. address="送货地址",
  335. deliveryTime="2026-10-09T00:00:00",
  336. products=[
  337. _stop_product(id="dp1", productName="片碱", planQty=Decimal("0.9")),
  338. _stop_product(id="dp2", productName="熟石灰", planQty=Decimal("1.75")),
  339. ],
  340. contracts=[],
  341. createTime="2026-10-09T00:00:00",
  342. updateTime="2026-10-09T00:00:00",
  343. )
  344. rows = _archive_rows(_bare_plan(plan_type="direct", customer_name=internal), load, [delivery])
  345. display = prepare_archive_list_display_rows(rows)
  346. assert [row["rowNo"] for row in display] == [1, 1, 1, 1]
  347. assert display[0]["deliveryCustomer"] == "凤阳京环"
  348. assert display[0]["loadCustomer"] == "合肥玉坤"
  349. assert [row["productName"] for row in display] == ["片碱", "熟石灰", "片碱", "熟石灰"]
  350. assert display[0]["_contractMergeKey"] != display[2]["_contractMergeKey"]
  351. def test_merge_sheet_matches_list_rowspan():
  352. from openpyxl import Workbook
  353. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  354. load = PlanLoadRead(
  355. id="load1",
  356. planId="p1",
  357. tenantNo="t1",
  358. customerName=internal,
  359. products=[
  360. _stop_product(id="lp1", productName="氯化钠", planQty=Decimal("0.45")),
  361. _stop_product(id="lp2", productName="次氯酸钠", planQty=Decimal("1.17")),
  362. ],
  363. contracts=[],
  364. createTime="2026-10-07T00:00:00",
  365. updateTime="2026-10-07T00:00:00",
  366. )
  367. delivery_a = PlanDeliveryRead(
  368. id="del1",
  369. planId="p1",
  370. tenantNo="t1",
  371. seq=1,
  372. customerName="安庆奇创",
  373. deliveryTime="2026-10-07T00:00:00",
  374. products=[_stop_product(id="dp1", productName="次氯酸钠", planQty=Decimal("1.17"))],
  375. contracts=[],
  376. createTime="2026-10-07T00:00:00",
  377. updateTime="2026-10-07T00:00:00",
  378. )
  379. delivery_b = PlanDeliveryRead(
  380. id="del2",
  381. planId="p1",
  382. tenantNo="t1",
  383. seq=2,
  384. customerName="安庆奇创",
  385. deliveryTime="2026-10-07T00:00:00",
  386. products=[_stop_product(id="dp2", productName="氯化钠", planQty=Decimal("0.45"))],
  387. contracts=[],
  388. createTime="2026-10-07T00:00:00",
  389. updateTime="2026-10-07T00:00:00",
  390. )
  391. rows = _archive_rows(_bare_plan(plan_type="outbound"), load, [delivery_a, delivery_b])
  392. workbook = Workbook()
  393. workbook.active.title = "计划归档"
  394. append_archive_list_merge_sheet(workbook, _MERGE_COLUMNS, rows, _CellResolver())
  395. sheet = workbook[ARCHIVE_MERGED_SHEET_TITLE]
  396. assert [cell.value for cell in sheet[1]] == [column.title for column in _MERGE_COLUMNS]
  397. assert sheet["F2"].value == "氯化钠"
  398. assert sheet["F5"].value == "氯化钠"
  399. ranges = _merged_ranges(sheet)
  400. assert "A2:A5" in ranges
  401. assert "B2:B5" in ranges
  402. assert "C2:C5" in ranges
  403. assert "D2:D3" in ranges
  404. assert "D4:D5" not in ranges
  405. assert sheet["D2"].value == "—"
  406. assert sheet["D4"].value == "安庆奇创"
  407. assert sheet["D5"].value == "安庆奇创"
  408. assert "F2:F5" not in ranges