test_pl_plan_archive_export.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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. brand_by_stop=None,
  71. brand_by_plan_product=None,
  72. ):
  73. if legacy_products is None:
  74. legacy_products = {}
  75. if alias_maps is None:
  76. alias_maps = ({}, {})
  77. if brand_by_stop is None:
  78. brand_by_stop = {}
  79. if brand_by_plan_product is None:
  80. brand_by_plan_product = {}
  81. with patch(
  82. "app.services.pl_plan_archive_export.load_plan_stops_grouped",
  83. return_value={plan.id: (load, deliveries)},
  84. ), patch(
  85. "app.services.pl_plan_archive_export._load_legacy_products_by_plan",
  86. return_value=legacy_products,
  87. ), patch(
  88. "app.services.pl_plan_archive_export._load_contract_nos_by_plan",
  89. return_value={plan.id: []},
  90. ), patch(
  91. "app.services.pl_plan_archive_export.load_crm_product_alias_maps",
  92. return_value=alias_maps,
  93. ), patch(
  94. "app.services.pl_plan_archive_export.load_brand_qtys_by_stop_product_ids",
  95. return_value=brand_by_stop,
  96. ), patch(
  97. "app.services.pl_plan_archive_export.load_brand_qtys_by_plan_product_ids",
  98. return_value=brand_by_plan_product,
  99. ):
  100. return build_plan_archive_export_rows(
  101. MagicMock(), "t1", [plan], {}, stop_kind=stop_kind
  102. )
  103. def test_build_plan_archive_export_rows_segment_rows_and_single_side():
  104. """默认按段次×产品展开:1 个装货产品 + 1 个送货产品 = 2 行;单侧筛选只留对应段。"""
  105. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  106. load_product = _stop_product(
  107. planQty=Decimal("10"),
  108. inboundQty=Decimal("10"),
  109. inboundPrice=Decimal("100"),
  110. )
  111. delivery_product = _stop_product(
  112. id="dp1",
  113. planQty=Decimal("12"),
  114. outboundQty=Decimal("9"),
  115. outboundPrice=Decimal("110"),
  116. outboundAmount=Decimal("990"),
  117. )
  118. load = PlanLoadRead(
  119. id="load1",
  120. planId="p1",
  121. tenantNo="t1",
  122. customerName=internal,
  123. address="装货地址",
  124. loadTime="2026-08-27T08:00:00",
  125. products=[load_product],
  126. createTime="2026-08-27T00:00:00",
  127. updateTime="2026-08-27T00:00:00",
  128. )
  129. delivery = PlanDeliveryRead(
  130. id="del1",
  131. planId="p1",
  132. tenantNo="t1",
  133. seq=2,
  134. customerName="外部客户",
  135. address="送货地址",
  136. deliveryTime="2026-08-28T10:00:00",
  137. products=[delivery_product],
  138. createTime="2026-08-27T00:00:00",
  139. updateTime="2026-08-27T00:00:00",
  140. )
  141. plan = _bare_plan()
  142. rows = _archive_rows(plan, load, [delivery], stop_kind=None)
  143. load_only = _archive_rows(plan, load, [delivery], stop_kind="load")
  144. delivery_only = _archive_rows(plan, load, [delivery], stop_kind="delivery")
  145. assert len(rows) == 2
  146. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #2"]
  147. assert rows[0]["productName"] == "甲醇"
  148. assert rows[1]["productName"] == "甲醇"
  149. assert rows[0]["loadCustomer"] == internal
  150. assert rows[0]["deliveryCustomer"] == ""
  151. assert rows[0]["loadAddress"] == "装货地址"
  152. assert rows[0]["loadTime"] == "2026-08-27"
  153. assert rows[0]["deliveryAddress"] == ""
  154. assert rows[0]["deliveryTime"] == ""
  155. assert rows[0]["planQty"] == Decimal("10")
  156. assert rows[0]["actualQty"] == Decimal("10")
  157. assert rows[0]["unitPrice"] == Decimal("100")
  158. assert rows[0]["amount"] == Decimal("1000")
  159. assert rows[1]["loadCustomer"] == ""
  160. assert rows[1]["deliveryCustomer"] == "外部客户"
  161. assert rows[1]["loadAddress"] == ""
  162. assert rows[1]["loadTime"] == ""
  163. assert rows[1]["deliveryAddress"] == "送货地址"
  164. assert rows[1]["deliveryTime"] == "2026-08-28"
  165. assert rows[1]["planQty"] == Decimal("12")
  166. assert rows[1]["actualQty"] == Decimal("9")
  167. assert rows[1]["unitPrice"] == Decimal("110")
  168. assert rows[1]["amount"] == Decimal("990")
  169. assert rows[0].get("loadPlanQty") is None
  170. assert rows[0].get("deliveryPlanQty") is None
  171. assert rows[1].get("loadPlanQty") is None
  172. assert rows[1].get("deliveryPlanQty") is None
  173. assert not rows[0].get("deliveryStopKind")
  174. assert not rows[1].get("deliveryStopKind")
  175. assert len(load_only) == 1
  176. assert load_only[0]["stopSegment"] == "装货"
  177. assert load_only[0]["loadCustomer"] == internal
  178. assert load_only[0]["deliveryCustomer"] == ""
  179. assert load_only[0]["planQty"] == Decimal("10")
  180. assert load_only[0]["actualQty"] == Decimal("10")
  181. assert load_only[0]["unitPrice"] == Decimal("100")
  182. assert load_only[0]["amount"] == Decimal("1000")
  183. assert load_only[0].get("loadPlanQty") is None
  184. assert len(delivery_only) == 1
  185. assert delivery_only[0]["stopSegment"] == "送货 #2"
  186. assert delivery_only[0]["deliveryCustomer"] == "外部客户"
  187. assert delivery_only[0]["loadCustomer"] == ""
  188. assert delivery_only[0]["planQty"] == Decimal("12")
  189. assert delivery_only[0]["actualQty"] == Decimal("9")
  190. assert delivery_only[0]["unitPrice"] == Decimal("110")
  191. assert delivery_only[0]["amount"] == Decimal("990")
  192. assert delivery_only[0].get("deliveryPlanQty") is None
  193. class _FakePlan:
  194. id = "p1"
  195. plan_no = "PL001"
  196. contract_no = "SX/X-JT20260827001"
  197. plan_date = "2026-08-27"
  198. archive_status = "archived"
  199. follow_status = "arranged"
  200. plan_type = "direct"
  201. license_plate = "皖A12345"
  202. trailer_no = "挂001"
  203. erp_entered = 1
  204. freight_amount = Decimal("500")
  205. remark = "测试"
  206. def test_plan_common_export_fields():
  207. fields = _plan_common_export_fields(_FakePlan(), None)
  208. assert fields["planId"] == "p1"
  209. assert fields["planNo"] == "PL001"
  210. assert fields["contractNo"] == "SX/X-JT20260827001"
  211. assert fields["archiveStatus"] == "已归档"
  212. assert fields["followStatus"] == "已安排"
  213. assert fields["planType"] == "直销单"
  214. assert fields["licensePlate"] == "皖A12345挂001"
  215. assert fields["erpEntered"] == "是"
  216. def test_build_plan_archive_export_rows_includes_brand_and_alias():
  217. plan_product = MagicMock()
  218. plan_product.id = "pp1"
  219. plan_product.product_id = "prod1"
  220. plan_product.product_name = "甲醇别名"
  221. plan_product.brand_mode = "any"
  222. plan_product.brand_name = None
  223. load_product = _stop_product(id="lp1", planProductId="pp1", productName="甲醇别名")
  224. delivery_product = _stop_product(
  225. id="dp1",
  226. planProductId="pp1",
  227. productName="甲醇别名",
  228. outboundQty=Decimal("9"),
  229. outboundPrice=Decimal("110"),
  230. )
  231. load = PlanLoadRead(
  232. id="load1",
  233. planId="p1",
  234. tenantNo="t1",
  235. customerName="装货客户",
  236. products=[load_product],
  237. createTime="2026-08-27T00:00:00",
  238. updateTime="2026-08-27T00:00:00",
  239. )
  240. delivery = PlanDeliveryRead(
  241. id="del1",
  242. planId="p1",
  243. tenantNo="t1",
  244. seq=1,
  245. customerName="送货客户",
  246. products=[delivery_product],
  247. createTime="2026-08-27T00:00:00",
  248. updateTime="2026-08-27T00:00:00",
  249. )
  250. rows = _archive_rows(
  251. _bare_plan(),
  252. load,
  253. [delivery],
  254. legacy_products={"p1": [plan_product]},
  255. alias_maps=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}),
  256. brand_by_stop={
  257. "lp1": [{"brandId": "b1", "brandName": "君正", "qty": Decimal("10")}],
  258. "dp1": [{"brandId": "b1", "brandName": "君正", "qty": Decimal("9")}],
  259. },
  260. )
  261. assert len(rows) == 2
  262. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"]
  263. assert rows[0]["loadCustomer"] == "装货客户"
  264. assert rows[0]["deliveryCustomer"] == ""
  265. assert rows[1]["loadCustomer"] == ""
  266. assert rows[1]["deliveryCustomer"] == "送货客户"
  267. for row in rows:
  268. assert row["productName"] == "甲醇"
  269. assert row["brandName"] == "君正"
  270. assert row["aliasName"] == "甲醇别名"
  271. assert rows[0]["actualQty"] == Decimal("10")
  272. assert rows[1]["actualQty"] == Decimal("9")
  273. def test_build_plan_archive_export_rows_splits_multi_brand():
  274. plan_product = MagicMock()
  275. plan_product.id = "pp1"
  276. plan_product.product_id = "prod1"
  277. plan_product.product_name = "片碱"
  278. plan_product.brand_mode = "any"
  279. plan_product.brand_name = None
  280. load_product = _stop_product(
  281. id="lp1",
  282. planProductId="pp1",
  283. productName="片碱",
  284. planQty=Decimal("34"),
  285. inboundQty=Decimal("32"),
  286. inboundPrice=Decimal("2820"),
  287. )
  288. load = PlanLoadRead(
  289. id="load1",
  290. planId="p1",
  291. tenantNo="t1",
  292. customerName="装货客户",
  293. products=[load_product],
  294. createTime="2026-08-27T00:00:00",
  295. updateTime="2026-08-27T00:00:00",
  296. )
  297. rows = _archive_rows(
  298. _bare_plan(),
  299. load,
  300. [],
  301. legacy_products={"p1": [plan_product]},
  302. alias_maps=({"prod1": "片碱"}, {"prod1": []}),
  303. brand_by_stop={
  304. "lp1": [
  305. {
  306. "brandId": "b1",
  307. "brandName": "君正",
  308. "qty": Decimal("20"),
  309. "unitPrice": Decimal("2820"),
  310. },
  311. {
  312. "brandId": "b2",
  313. "brandName": "红四方",
  314. "qty": Decimal("12"),
  315. "unitPrice": Decimal("2820"),
  316. },
  317. ]
  318. },
  319. )
  320. load_rows = [r for r in rows if r.get("_stopKind") == "load"]
  321. assert len(load_rows) == 2
  322. assert [r["brandName"] for r in load_rows] == ["君正", "红四方"]
  323. assert [r["actualQty"] for r in load_rows] == [Decimal("20"), Decimal("12")]
  324. assert all(r["planQty"] == Decimal("34") for r in load_rows)
  325. assert load_rows[0]["amount"] == Decimal("20") * Decimal("2820")
  326. def test_plan_common_export_fields_merges_link_contract_nos():
  327. fields = _plan_common_export_fields(
  328. _FakePlan(),
  329. None,
  330. contract_nos=["SX/X-JT20260827001", "SX/X-JT20260827002"],
  331. )
  332. assert fields["contractNo"] == "SX/X-JT20260827001、SX/X-JT20260827002"
  333. def test_build_plan_archive_export_rows_internal_customer_contract_per_segment():
  334. """内部客户缺合同:装货行合同为出库,送货行为入库,不再拼在同一行。"""
  335. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  336. load_product = _stop_product(id="lp1", inboundQty=Decimal("10"))
  337. delivery_product = _stop_product(id="dp1", outboundQty=Decimal("10"))
  338. load = PlanLoadRead(
  339. id="load1",
  340. planId="p1",
  341. tenantNo="t1",
  342. customerName=internal,
  343. products=[load_product],
  344. contracts=[],
  345. createTime="2026-08-27T00:00:00",
  346. updateTime="2026-08-27T00:00:00",
  347. )
  348. delivery = PlanDeliveryRead(
  349. id="del1",
  350. planId="p1",
  351. tenantNo="t1",
  352. seq=1,
  353. customerName=internal,
  354. products=[delivery_product],
  355. contracts=[],
  356. createTime="2026-08-27T00:00:00",
  357. updateTime="2026-08-27T00:00:00",
  358. )
  359. rows = _archive_rows(_bare_plan(contract_no=""), load, [delivery])
  360. assert len(rows) == 2
  361. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"]
  362. assert rows[0]["contractNo"] == "出库"
  363. assert rows[0]["loadCustomer"] == internal
  364. assert rows[0]["deliveryCustomer"] == ""
  365. assert rows[1]["contractNo"] == "入库"
  366. assert rows[1]["deliveryCustomer"] == internal
  367. assert rows[1]["loadCustomer"] == ""
  368. class _CellResolver:
  369. def row_to_cells(self, row, columns):
  370. return ["" if row.get(column.field) is None else row.get(column.field) for column in columns]
  371. _MERGE_COLUMNS = [
  372. ExportColumn(title="序号", field="rowNo"),
  373. ExportColumn(title="计划编号", field="planNo"),
  374. ExportColumn(title="装货客户", field="loadCustomer"),
  375. ExportColumn(title="送货客户", field="deliveryCustomer"),
  376. ExportColumn(title="合同编号", field="contractNo"),
  377. ExportColumn(title="产品名称", field="productName"),
  378. ExportColumn(title="计划量", field="planQty"),
  379. ]
  380. def _merged_ranges(sheet):
  381. return {str(item) for item in sheet.merged_cells.ranges}
  382. def test_prepare_single_delivery_puts_delivery_customer_on_first_row():
  383. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  384. load = PlanLoadRead(
  385. id="load1",
  386. planId="p1",
  387. tenantNo="t1",
  388. customerName="合肥玉坤",
  389. address="装货地址",
  390. loadTime="2026-10-09T00:00:00",
  391. products=[
  392. _stop_product(id="lp1", productName="片碱", planQty=Decimal("0.9")),
  393. _stop_product(id="lp2", productName="熟石灰", planQty=Decimal("1.75")),
  394. ],
  395. contracts=[],
  396. createTime="2026-10-09T00:00:00",
  397. updateTime="2026-10-09T00:00:00",
  398. )
  399. delivery = PlanDeliveryRead(
  400. id="del1",
  401. planId="p1",
  402. tenantNo="t1",
  403. seq=1,
  404. customerName="凤阳京环",
  405. address="送货地址",
  406. deliveryTime="2026-10-09T00:00:00",
  407. products=[
  408. _stop_product(id="dp1", productName="片碱", planQty=Decimal("0.9")),
  409. _stop_product(id="dp2", productName="熟石灰", planQty=Decimal("1.75")),
  410. ],
  411. contracts=[],
  412. createTime="2026-10-09T00:00:00",
  413. updateTime="2026-10-09T00:00:00",
  414. )
  415. rows = _archive_rows(_bare_plan(plan_type="direct", customer_name=internal), load, [delivery])
  416. display = prepare_archive_list_display_rows(rows)
  417. assert [row["rowNo"] for row in display] == [1, 1, 1, 1]
  418. assert display[0]["deliveryCustomer"] == "凤阳京环"
  419. assert display[0]["loadCustomer"] == "合肥玉坤"
  420. assert [row["productName"] for row in display] == ["片碱", "熟石灰", "片碱", "熟石灰"]
  421. assert display[0]["_contractMergeKey"] != display[2]["_contractMergeKey"]
  422. def test_merge_sheet_matches_list_rowspan():
  423. from openpyxl import Workbook
  424. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  425. load = PlanLoadRead(
  426. id="load1",
  427. planId="p1",
  428. tenantNo="t1",
  429. customerName=internal,
  430. products=[
  431. _stop_product(id="lp1", productName="氯化钠", planQty=Decimal("0.45")),
  432. _stop_product(id="lp2", productName="次氯酸钠", planQty=Decimal("1.17")),
  433. ],
  434. contracts=[],
  435. createTime="2026-10-07T00:00:00",
  436. updateTime="2026-10-07T00:00:00",
  437. )
  438. delivery_a = PlanDeliveryRead(
  439. id="del1",
  440. planId="p1",
  441. tenantNo="t1",
  442. seq=1,
  443. customerName="安庆奇创",
  444. deliveryTime="2026-10-07T00:00:00",
  445. products=[_stop_product(id="dp1", productName="次氯酸钠", planQty=Decimal("1.17"))],
  446. contracts=[],
  447. createTime="2026-10-07T00:00:00",
  448. updateTime="2026-10-07T00:00:00",
  449. )
  450. delivery_b = PlanDeliveryRead(
  451. id="del2",
  452. planId="p1",
  453. tenantNo="t1",
  454. seq=2,
  455. customerName="安庆奇创",
  456. deliveryTime="2026-10-07T00:00:00",
  457. products=[_stop_product(id="dp2", productName="氯化钠", planQty=Decimal("0.45"))],
  458. contracts=[],
  459. createTime="2026-10-07T00:00:00",
  460. updateTime="2026-10-07T00:00:00",
  461. )
  462. rows = _archive_rows(_bare_plan(plan_type="outbound"), load, [delivery_a, delivery_b])
  463. workbook = Workbook()
  464. workbook.active.title = "计划归档"
  465. append_archive_list_merge_sheet(workbook, _MERGE_COLUMNS, rows, _CellResolver())
  466. sheet = workbook[ARCHIVE_MERGED_SHEET_TITLE]
  467. assert [cell.value for cell in sheet[1]] == [column.title for column in _MERGE_COLUMNS]
  468. assert sheet["F2"].value == "氯化钠"
  469. assert sheet["F5"].value == "氯化钠"
  470. ranges = _merged_ranges(sheet)
  471. assert "A2:A5" in ranges
  472. assert "B2:B5" in ranges
  473. assert "C2:C5" in ranges
  474. assert "D2:D3" in ranges
  475. assert "D4:D5" not in ranges
  476. assert sheet["D2"].value == "—"
  477. assert sheet["D4"].value == "安庆奇创"
  478. assert sheet["D5"].value == "安庆奇创"
  479. assert "F2:F5" not in ranges