test_pl_plan_list_export.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. """运输计划列表导出:产品名称 / 品牌 / 别名 / 状态中文 / 列表列对齐。"""
  2. from decimal import Decimal
  3. from types import SimpleNamespace
  4. from unittest.mock import MagicMock, patch
  5. from app.api.pl.plan import (
  6. PLAN_EXPORT_COLUMNS,
  7. _build_plan_list_export_rows,
  8. _normalize_plan_export_columns,
  9. _plan_list_export_filename_stem,
  10. )
  11. from app.utils.excel_export import filter_export_columns
  12. def test_normalize_plan_export_columns_maps_list_props():
  13. assert _normalize_plan_export_columns(
  14. [
  15. "productBrand",
  16. "productAlias",
  17. "productName",
  18. "planNo",
  19. "productSpec",
  20. "productPackageSpec",
  21. "productUnit",
  22. "planQty",
  23. "actualQty",
  24. ]
  25. ) == [
  26. "brandName",
  27. "aliasName",
  28. "productName",
  29. "planNo",
  30. "model",
  31. "packageSpec",
  32. "unit",
  33. "planQty",
  34. "actualQty",
  35. ]
  36. assert _normalize_plan_export_columns(None) is None
  37. def test_filter_export_columns_keeps_list_props_after_normalize():
  38. requested = _normalize_plan_export_columns(
  39. [
  40. "planNo",
  41. "belongsCompany",
  42. "followStatus",
  43. "planType",
  44. "loadCustomer",
  45. "deliveryCustomer",
  46. "productName",
  47. "productBrand",
  48. "productAlias",
  49. "productSpec",
  50. "productPackageSpec",
  51. "productUnit",
  52. "planQty",
  53. "actualQty",
  54. ]
  55. )
  56. cols = filter_export_columns(PLAN_EXPORT_COLUMNS, requested)
  57. assert [c.field for c in cols] == [
  58. "planNo",
  59. "belongsCompany",
  60. "followStatus",
  61. "planType",
  62. "loadCustomer",
  63. "deliveryCustomer",
  64. "productName",
  65. "brandName",
  66. "aliasName",
  67. "model",
  68. "packageSpec",
  69. "unit",
  70. "planQty",
  71. "actualQty",
  72. ]
  73. def test_plan_list_export_filename_stem_by_stock_role():
  74. assert _plan_list_export_filename_stem("inbound", "20261015") == "入库单_20261015"
  75. assert _plan_list_export_filename_stem("outbound", "20261015") == "出库单_20261015"
  76. assert _plan_list_export_filename_stem(None, "20261015") == "计划列表_20261015"
  77. def test_build_plan_list_export_rows_expands_products_with_crm_names():
  78. plan = SimpleNamespace(
  79. id="p1",
  80. plan_no="PL001",
  81. follow_status="arranged",
  82. plan_date="2026-10-11",
  83. plan_type="direct",
  84. supplier_name="供方",
  85. customer_name="需方",
  86. license_plate="皖A12345",
  87. trailer_no=None,
  88. driver_name="司机",
  89. transport_status="arranged",
  90. archive_status="unarchived",
  91. erp_entered=0,
  92. remark="",
  93. products=[],
  94. )
  95. # 计划级产品只用于品牌 lookup;行展开走段产品。
  96. product = SimpleNamespace(
  97. id="pp1",
  98. plan_id="p1",
  99. product_id="prod1",
  100. product_name="甲醇别名",
  101. brand_mode="required",
  102. brand_name="君正",
  103. model="工业级",
  104. package_spec="吨袋",
  105. unit="ton",
  106. quantity=10,
  107. actual_qty=9.5,
  108. order_num=1,
  109. )
  110. load_product = SimpleNamespace(
  111. id="lp1",
  112. plan_product_id="pp1",
  113. product_id="prod1",
  114. product_name="甲醇别名",
  115. model="工业级",
  116. package_spec="吨袋",
  117. unit="ton",
  118. plan_qty=10,
  119. inbound_qty=9.5,
  120. outbound_qty=None,
  121. )
  122. delivery_product = SimpleNamespace(
  123. id="dp1",
  124. plan_product_id="pp1",
  125. product_id="prod1",
  126. product_name="甲醇别名",
  127. model="工业级",
  128. package_spec="吨袋",
  129. unit="ton",
  130. plan_qty=10,
  131. inbound_qty=None,
  132. outbound_qty=9.5,
  133. )
  134. db = MagicMock()
  135. query = MagicMock()
  136. db.query.return_value = query
  137. query.filter.return_value = query
  138. query.order_by.return_value = query
  139. query.all.return_value = [product]
  140. load_stop = SimpleNamespace(
  141. customer_name="装货客户A",
  142. address="装货路1号",
  143. load_time="2026-10-11",
  144. generate_dibang=1,
  145. dibang_direction="outbound",
  146. contracts=[SimpleNamespace(contract_no="HT001")],
  147. products=[load_product],
  148. )
  149. delivery_stop = SimpleNamespace(
  150. seq=1,
  151. customer_name="送货客户B",
  152. address="送货路2号",
  153. delivery_time="2026-10-12",
  154. generate_dibang=1,
  155. dibang_direction="inbound",
  156. contracts=[SimpleNamespace(contract_no="HT002")],
  157. products=[delivery_product],
  158. )
  159. fake_dump = {
  160. "planNo": "PL001",
  161. "belongsCompany": "shanxia",
  162. "followStatus": "arranged",
  163. "planDate": "2026-10-11",
  164. "planType": "inbound",
  165. "supplierName": "供方",
  166. "customerName": "需方",
  167. "licensePlate": "皖A12345",
  168. "trailerNo": None,
  169. "personnelSummary": "司机张三",
  170. "followUserName": "跟进人李四",
  171. "driverName": "司机",
  172. "transportStatus": "arranged",
  173. "archiveStatus": "unarchived",
  174. "erpEntered": 0,
  175. "remark": "",
  176. "products": [{"id": "pp1"}],
  177. }
  178. with patch(
  179. "app.api.pl.plan.PlanWithProducts.model_validate",
  180. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  181. ), patch(
  182. "app.api.pl.plan.load_crm_product_alias_maps",
  183. return_value=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}),
  184. ), patch(
  185. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  186. return_value={},
  187. ), patch(
  188. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  189. return_value={},
  190. ):
  191. rows = _build_plan_list_export_rows(
  192. db,
  193. tenant_no="t1",
  194. plans=[plan],
  195. stops_by_plan={"p1": (load_stop, [delivery_stop])},
  196. )
  197. assert len(rows) == 2
  198. assert [row["stopSegment"] for row in rows] == ["装货", "送货 #1"]
  199. assert rows[0]["_stopKind"] == "load"
  200. assert rows[1]["_stopKind"] == "delivery"
  201. assert rows[1]["_deliverySeq"] == 1
  202. load_row, delivery_row = rows
  203. for row in rows:
  204. assert row["planNo"] == "PL001"
  205. assert row["productName"] == "甲醇"
  206. # 未归档:取产品指定品牌,不读归档明细
  207. assert row["brandName"] == "君正"
  208. assert row["aliasName"] == "甲醇别名"
  209. assert row["followStatus"] == "已安排"
  210. assert row["planType"] == "入库单"
  211. assert row["belongsCompany"] == "安庆山峡"
  212. assert row["archiveStatus"] == "未归档"
  213. assert row["model"] == "工业级"
  214. assert row["packageSpec"] == "吨袋"
  215. assert row["unit"] == "ton"
  216. assert row["planQty"] == 10
  217. assert row["actualQty"] == 9.5
  218. assert "products" not in row
  219. assert load_row["deliveryTimes"] == ""
  220. assert load_row["loadCustomer"] == "装货客户A"
  221. assert load_row["deliveryCustomer"] == ""
  222. assert load_row["loadAddressTime"] == "装货路1号 / 2026-10-11"
  223. assert load_row["deliveryAddressTime"] == ""
  224. assert load_row["productContract"] == "HT001"
  225. assert load_row["loadDibang"] == "出库"
  226. assert load_row["deliveryDibang"] == ""
  227. assert delivery_row["deliveryTimes"] == "2026-10-12"
  228. assert delivery_row["loadCustomer"] == ""
  229. assert delivery_row["deliveryCustomer"] == "送货客户B"
  230. assert delivery_row["loadAddressTime"] == ""
  231. assert delivery_row["deliveryAddressTime"] == "送货路2号 / 2026-10-12"
  232. assert delivery_row["productContract"] == "HT002"
  233. assert delivery_row["loadDibang"] == ""
  234. assert delivery_row["deliveryDibang"] == "入库"
  235. def test_build_plan_list_export_rows_expands_stop_segments():
  236. """一装多送:先装货产品行,再各送货段产品行;侧栏字段按行填写。"""
  237. plan = SimpleNamespace(
  238. id="p-multi",
  239. plan_no="PO20261009016",
  240. follow_status="arranged",
  241. plan_date="2026-10-11",
  242. plan_type="inbound",
  243. supplier_name="供方",
  244. customer_name="需方",
  245. license_plate="皖A12345",
  246. trailer_no=None,
  247. driver_name="司机",
  248. transport_status="arranged",
  249. archive_status="unarchived",
  250. erp_entered=0,
  251. remark="",
  252. products=[],
  253. )
  254. db = MagicMock()
  255. query = MagicMock()
  256. db.query.return_value = query
  257. query.filter.return_value = query
  258. query.order_by.return_value = query
  259. # 有段结构时不应依赖计划级产品展开;返回空列表即可
  260. query.all.return_value = []
  261. load_product = SimpleNamespace(
  262. id="lp1",
  263. plan_product_id="pp1",
  264. product_id="prod1",
  265. product_name="尿素别名",
  266. model="工业级",
  267. package_spec="吨袋",
  268. unit="ton",
  269. plan_qty=30,
  270. inbound_qty=29,
  271. outbound_qty=None,
  272. brand_mode=None,
  273. brand_name=None,
  274. )
  275. del1_product = SimpleNamespace(
  276. id="dp1",
  277. plan_product_id="pp1",
  278. product_id="prod1",
  279. product_name="尿素别名",
  280. model="工业级",
  281. package_spec="吨袋",
  282. unit="ton",
  283. plan_qty=20,
  284. inbound_qty=None,
  285. outbound_qty=19,
  286. brand_mode=None,
  287. brand_name=None,
  288. )
  289. del2_product = SimpleNamespace(
  290. id="dp2",
  291. plan_product_id="pp1",
  292. product_id="prod1",
  293. product_name="尿素别名",
  294. model="工业级",
  295. package_spec="吨袋",
  296. unit="ton",
  297. plan_qty=10,
  298. inbound_qty=None,
  299. outbound_qty=9,
  300. brand_mode=None,
  301. brand_name=None,
  302. )
  303. load_stop = SimpleNamespace(
  304. customer_name="装货客户A",
  305. address="装货路1号",
  306. load_time="2026-10-11",
  307. generate_dibang=1,
  308. dibang_direction="outbound",
  309. contracts=[SimpleNamespace(contract_no="HT-L")],
  310. products=[load_product],
  311. )
  312. delivery1 = SimpleNamespace(
  313. seq=1,
  314. customer_name="送货客户B1",
  315. address="送货路B1",
  316. delivery_time="2026-10-12",
  317. generate_dibang=1,
  318. dibang_direction="inbound",
  319. contracts=[SimpleNamespace(contract_no="HT-B1")],
  320. products=[del1_product],
  321. )
  322. delivery2 = SimpleNamespace(
  323. seq=2,
  324. customer_name="送货客户B2",
  325. address="送货路B2",
  326. delivery_time="2026-10-13",
  327. generate_dibang=0,
  328. dibang_direction=None,
  329. contracts=[SimpleNamespace(contract_no="HT-B2")],
  330. products=[del2_product],
  331. )
  332. fake_dump = {
  333. "planNo": "PO20261009016",
  334. "belongsCompany": "shanxia",
  335. "followStatus": "arranged",
  336. "planDate": "2026-10-11",
  337. "planType": "inbound",
  338. "supplierName": "供方",
  339. "customerName": "需方",
  340. "licensePlate": "皖A12345",
  341. "trailerNo": None,
  342. "personnelSummary": "司机张三",
  343. "followUserName": "跟进人李四",
  344. "driverName": "司机",
  345. "transportStatus": "arranged",
  346. "archiveStatus": "unarchived",
  347. "erpEntered": 0,
  348. "remark": "",
  349. "products": [],
  350. }
  351. with patch(
  352. "app.api.pl.plan.PlanWithProducts.model_validate",
  353. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  354. ), patch(
  355. "app.api.pl.plan.load_crm_product_alias_maps",
  356. return_value=({"prod1": "尿素"}, {"prod1": ["尿素别名"]}),
  357. ), patch(
  358. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  359. return_value={},
  360. ), patch(
  361. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  362. return_value={},
  363. ):
  364. rows = _build_plan_list_export_rows(
  365. db,
  366. tenant_no="t1",
  367. plans=[plan],
  368. stops_by_plan={"p-multi": (load_stop, [delivery1, delivery2])},
  369. )
  370. assert len(rows) == 3
  371. assert [r["stopSegment"] for r in rows] == ["装货", "送货 #1", "送货 #2"]
  372. assert [r["planQty"] for r in rows] == [30, 20, 10]
  373. assert [r["actualQty"] for r in rows] == [29, 19, 9]
  374. assert rows[0]["loadCustomer"] == "装货客户A"
  375. assert rows[0]["deliveryCustomer"] == ""
  376. assert rows[0]["productContract"] == "HT-L"
  377. assert rows[1]["loadCustomer"] == ""
  378. assert rows[1]["deliveryCustomer"] == "送货客户B1"
  379. assert rows[1]["deliveryTimes"] == "2026-10-12"
  380. assert rows[1]["productContract"] == "HT-B1"
  381. assert rows[2]["deliveryCustomer"] == "送货客户B2"
  382. assert rows[2]["deliveryDibang"] == ""
  383. assert rows[0]["productName"] == "尿素"
  384. def test_build_plan_list_export_rows_empty_delivery_products_placeholder():
  385. """送货段无产品仍占一行;产品/量为空。"""
  386. plan = SimpleNamespace(
  387. id="p-empty",
  388. plan_no="PL-EMPTY",
  389. follow_status="arranged",
  390. plan_date="2026-10-11",
  391. plan_type="inbound",
  392. supplier_name="供方",
  393. customer_name="需方",
  394. license_plate="皖A12345",
  395. trailer_no=None,
  396. driver_name="司机",
  397. transport_status="arranged",
  398. archive_status="unarchived",
  399. erp_entered=0,
  400. remark="",
  401. products=[],
  402. )
  403. db = MagicMock()
  404. query = MagicMock()
  405. db.query.return_value = query
  406. query.filter.return_value = query
  407. query.order_by.return_value = query
  408. query.all.return_value = []
  409. load_product = SimpleNamespace(
  410. id="lp1",
  411. plan_product_id="pp1",
  412. product_id="prod1",
  413. product_name="甲醇别名",
  414. model="工业级",
  415. package_spec="吨袋",
  416. unit="ton",
  417. plan_qty=5,
  418. inbound_qty=5,
  419. outbound_qty=None,
  420. brand_mode=None,
  421. brand_name=None,
  422. )
  423. load_stop = SimpleNamespace(
  424. customer_name="装货客户A",
  425. address="装货路1号",
  426. load_time="2026-10-11",
  427. generate_dibang=1,
  428. dibang_direction="outbound",
  429. contracts=[SimpleNamespace(contract_no="HT-L")],
  430. products=[load_product],
  431. )
  432. delivery = SimpleNamespace(
  433. seq=1,
  434. customer_name="送B",
  435. address="送货路B",
  436. delivery_time="2026-10-12",
  437. generate_dibang=1,
  438. dibang_direction="inbound",
  439. contracts=[SimpleNamespace(contract_no="HT-B")],
  440. products=[],
  441. )
  442. fake_dump = {
  443. "planNo": "PL-EMPTY",
  444. "belongsCompany": "shanxia",
  445. "followStatus": "arranged",
  446. "planDate": "2026-10-11",
  447. "planType": "inbound",
  448. "supplierName": "供方",
  449. "customerName": "需方",
  450. "licensePlate": "皖A12345",
  451. "trailerNo": None,
  452. "personnelSummary": "司机张三",
  453. "followUserName": "跟进人李四",
  454. "driverName": "司机",
  455. "transportStatus": "arranged",
  456. "archiveStatus": "unarchived",
  457. "erpEntered": 0,
  458. "remark": "",
  459. "products": [],
  460. }
  461. with patch(
  462. "app.api.pl.plan.PlanWithProducts.model_validate",
  463. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  464. ), patch(
  465. "app.api.pl.plan.load_crm_product_alias_maps",
  466. return_value=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}),
  467. ), patch(
  468. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  469. return_value={},
  470. ), patch(
  471. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  472. return_value={},
  473. ):
  474. rows = _build_plan_list_export_rows(
  475. db,
  476. tenant_no="t1",
  477. plans=[plan],
  478. stops_by_plan={"p-empty": (load_stop, [delivery])},
  479. )
  480. assert len(rows) == 2
  481. assert rows[1]["stopSegment"] == "送货 #1"
  482. assert rows[1]["productName"] == ""
  483. assert rows[1]["planQty"] is None
  484. assert rows[1]["deliveryCustomer"] == "送B"
  485. def test_build_plan_list_export_rows_legacy_without_stops():
  486. """无段结构:回退计划级产品;stopSegment 为空。"""
  487. plan = SimpleNamespace(
  488. id="p1",
  489. plan_no="PL001",
  490. follow_status="arranged",
  491. plan_date="2026-10-11",
  492. plan_type="direct",
  493. supplier_name="供方",
  494. customer_name="需方",
  495. license_plate="皖A12345",
  496. trailer_no=None,
  497. driver_name="司机",
  498. transport_status="arranged",
  499. archive_status="unarchived",
  500. erp_entered=0,
  501. remark="",
  502. products=[],
  503. )
  504. product = SimpleNamespace(
  505. id="pp1",
  506. plan_id="p1",
  507. product_id="prod1",
  508. product_name="甲醇别名",
  509. brand_mode="required",
  510. brand_name="君正",
  511. model="工业级",
  512. package_spec="吨袋",
  513. unit="ton",
  514. quantity=10,
  515. actual_qty=9.5,
  516. order_num=1,
  517. )
  518. db = MagicMock()
  519. query = MagicMock()
  520. db.query.return_value = query
  521. query.filter.return_value = query
  522. query.order_by.return_value = query
  523. query.all.return_value = [product]
  524. fake_dump = {
  525. "planNo": "PL001",
  526. "belongsCompany": "shanxia",
  527. "followStatus": "arranged",
  528. "planDate": "2026-10-11",
  529. "planType": "inbound",
  530. "supplierName": "供方",
  531. "customerName": "需方",
  532. "licensePlate": "皖A12345",
  533. "trailerNo": None,
  534. "personnelSummary": "司机张三",
  535. "followUserName": "跟进人李四",
  536. "driverName": "司机",
  537. "transportStatus": "arranged",
  538. "archiveStatus": "unarchived",
  539. "erpEntered": 0,
  540. "remark": "",
  541. "products": [{"id": "pp1"}],
  542. }
  543. with patch(
  544. "app.api.pl.plan.PlanWithProducts.model_validate",
  545. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  546. ), patch(
  547. "app.api.pl.plan.load_crm_product_alias_maps",
  548. return_value=({"prod1": "甲醇"}, {"prod1": ["甲醇别名"]}),
  549. ), patch(
  550. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  551. return_value={},
  552. ), patch(
  553. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  554. return_value={},
  555. ):
  556. rows = _build_plan_list_export_rows(
  557. db,
  558. tenant_no="t1",
  559. plans=[plan],
  560. stops_by_plan={"p1": (None, [])},
  561. )
  562. assert len(rows) == 1
  563. assert rows[0]["stopSegment"] == ""
  564. assert rows[0]["planQty"] == 10
  565. assert rows[0]["productName"] == "甲醇"
  566. assert rows[0]["brandName"] == "君正"
  567. def test_build_plan_list_export_rows_internal_customer_contract_placeholder():
  568. """山峡/云栋缺合同:装货行合同为出库,送货行为入库(与列表 UI 一致)。"""
  569. from app.services.pl_internal_customer import INTERNAL_CUSTOMER_NAMES
  570. internal = next(iter(INTERNAL_CUSTOMER_NAMES))
  571. plan = SimpleNamespace(
  572. id="p-io",
  573. plan_no="PL-IO",
  574. follow_status="arranged",
  575. plan_date="2026-10-11",
  576. plan_type="direct",
  577. supplier_name=internal,
  578. customer_name=internal,
  579. license_plate="皖A1",
  580. trailer_no=None,
  581. driver_name="",
  582. transport_status="arranged",
  583. archive_status="unarchived",
  584. erp_entered=0,
  585. remark="",
  586. products=[],
  587. )
  588. db = MagicMock()
  589. query = MagicMock()
  590. db.query.return_value = query
  591. query.filter.return_value = query
  592. query.order_by.return_value = query
  593. query.all.return_value = []
  594. stop_product = SimpleNamespace(
  595. id="sp1",
  596. plan_product_id=None,
  597. product_id="prod1",
  598. product_name="尿素",
  599. model="",
  600. package_spec="",
  601. unit="ton",
  602. plan_qty=5,
  603. inbound_qty=None,
  604. outbound_qty=None,
  605. )
  606. load_stop = SimpleNamespace(
  607. customer_name=internal,
  608. address="装货地址",
  609. load_time="2026-10-11",
  610. generate_dibang=0,
  611. dibang_direction=None,
  612. contracts=[],
  613. products=[stop_product],
  614. )
  615. delivery_stop = SimpleNamespace(
  616. seq=1,
  617. customer_name=internal,
  618. address="送货地址",
  619. delivery_time="2026-10-12",
  620. generate_dibang=0,
  621. dibang_direction=None,
  622. contracts=[],
  623. products=[
  624. SimpleNamespace(
  625. id="sp2",
  626. plan_product_id=None,
  627. product_id="prod1",
  628. product_name="尿素",
  629. model="",
  630. package_spec="",
  631. unit="ton",
  632. plan_qty=5,
  633. inbound_qty=None,
  634. outbound_qty=5,
  635. )
  636. ],
  637. )
  638. fake_dump = {
  639. "planNo": "PL-IO",
  640. "belongsCompany": "shanxia",
  641. "followStatus": "arranged",
  642. "planDate": "2026-10-11",
  643. "planType": "direct",
  644. "supplierName": internal,
  645. "customerName": internal,
  646. "licensePlate": "皖A1",
  647. "trailerNo": None,
  648. "personnelSummary": "",
  649. "followUserName": "",
  650. "driverName": "",
  651. "transportStatus": "arranged",
  652. "archiveStatus": "unarchived",
  653. "erpEntered": 0,
  654. "remark": "",
  655. "products": [],
  656. }
  657. with patch(
  658. "app.api.pl.plan.PlanWithProducts.model_validate",
  659. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  660. ), patch(
  661. "app.api.pl.plan.load_crm_product_alias_maps",
  662. return_value=({"prod1": "尿素"}, {"prod1": []}),
  663. ), patch(
  664. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  665. return_value={},
  666. ), patch(
  667. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  668. return_value={},
  669. ):
  670. rows = _build_plan_list_export_rows(
  671. db,
  672. tenant_no="t1",
  673. plans=[plan],
  674. stops_by_plan={"p-io": (load_stop, [delivery_stop])},
  675. )
  676. assert len(rows) == 2
  677. assert rows[0]["stopSegment"] == "装货"
  678. assert rows[0]["productContract"] == "出库"
  679. assert rows[1]["stopSegment"] == "送货 #1"
  680. assert rows[1]["productContract"] == "入库"
  681. def test_build_plan_list_export_rows_splits_by_archive_brand_qty():
  682. """段产品有多个归档品牌时按品牌拆行,实际量取品牌量。"""
  683. plan = SimpleNamespace(
  684. id="p-brand",
  685. plan_no="PO-BRAND",
  686. follow_status="completed",
  687. plan_date="2026-10-20",
  688. plan_type="inbound",
  689. supplier_name="供方",
  690. customer_name="需方",
  691. license_plate="一票到",
  692. trailer_no=None,
  693. driver_name="",
  694. transport_status="arranged",
  695. archive_status="archived",
  696. erp_entered=1,
  697. remark="",
  698. products=[],
  699. )
  700. product = SimpleNamespace(
  701. id="pp1",
  702. plan_id="p-brand",
  703. product_id="prod1",
  704. product_name="片碱",
  705. brand_mode="any",
  706. brand_name=None,
  707. model=None,
  708. package_spec="25Kg/袋",
  709. unit="dun",
  710. quantity=34,
  711. actual_qty=32,
  712. order_num=0,
  713. )
  714. load_product = SimpleNamespace(
  715. id="lp1",
  716. plan_product_id="pp1",
  717. product_id="prod1",
  718. product_name="片碱",
  719. model=None,
  720. package_spec="25Kg/袋",
  721. unit="dun",
  722. plan_qty=34,
  723. inbound_qty=32,
  724. outbound_qty=None,
  725. )
  726. db = MagicMock()
  727. query = MagicMock()
  728. db.query.return_value = query
  729. query.filter.return_value = query
  730. query.order_by.return_value = query
  731. query.all.return_value = [product]
  732. load_stop = SimpleNamespace(
  733. customer_name="南昌明瑞",
  734. address="",
  735. load_time="2026-10-20",
  736. generate_dibang=0,
  737. dibang_direction="none",
  738. contracts=[],
  739. products=[load_product],
  740. )
  741. fake_dump = {
  742. "planNo": "PO-BRAND",
  743. "belongsCompany": "shanxia",
  744. "followStatus": "completed",
  745. "planDate": "2026-10-20",
  746. "planType": "inbound",
  747. "supplierName": "供方",
  748. "customerName": "需方",
  749. "licensePlate": "一票到",
  750. "trailerNo": None,
  751. "personnelSummary": "",
  752. "followUserName": "",
  753. "driverName": "",
  754. "transportStatus": "arranged",
  755. "archiveStatus": "archived",
  756. "erpEntered": 1,
  757. "remark": "",
  758. "products": [],
  759. }
  760. with patch(
  761. "app.api.pl.plan.PlanWithProducts.model_validate",
  762. return_value=MagicMock(model_dump=MagicMock(return_value=dict(fake_dump))),
  763. ), patch(
  764. "app.api.pl.plan.load_crm_product_alias_maps",
  765. return_value=({"prod1": "片碱"}, {"prod1": []}),
  766. ), patch(
  767. "app.api.pl.plan.load_brand_qtys_by_plan_product_ids",
  768. return_value={},
  769. ), patch(
  770. "app.api.pl.plan.load_brand_qtys_by_stop_product_ids",
  771. return_value={
  772. "lp1": [
  773. {"brandId": "b1", "brandName": "君正", "qty": Decimal("20")},
  774. {"brandId": "b2", "brandName": "红四方", "qty": Decimal("12")},
  775. ]
  776. },
  777. ):
  778. rows = _build_plan_list_export_rows(
  779. db,
  780. tenant_no="t1",
  781. plans=[plan],
  782. stops_by_plan={"p-brand": (load_stop, [])},
  783. )
  784. load_rows = [r for r in rows if r.get("_stopKind") == "load"]
  785. assert len(load_rows) == 2
  786. assert [r["brandName"] for r in load_rows] == ["君正", "红四方"]
  787. assert [r["actualQty"] for r in load_rows] == [Decimal("20"), Decimal("12")]
  788. assert all(r["planQty"] == 34 for r in load_rows)
  789. assert all(r["stopSegment"] == "装货" for r in load_rows)
  790. def test_plan_list_merge_sheet_follows_list_rowspan():
  791. from openpyxl import Workbook
  792. from app.services.pl_plan_archive_export import (
  793. _PLAN_LIST_DELIVERY_MERGE_FIELDS,
  794. _PLAN_LIST_LOAD_COPY_FIELDS,
  795. _PLAN_LIST_PLAN_MERGE_FIELDS,
  796. append_archive_list_merge_sheet,
  797. )
  798. from app.utils.excel_export import ExportColumn
  799. class Resolver:
  800. def row_to_cells(self, row, columns):
  801. return ["" if row.get(column.field) is None else row.get(column.field) for column in columns]
  802. columns = [
  803. ExportColumn(title="计划编号", field="planNo"),
  804. ExportColumn(title="送货时间", field="deliveryTimes"),
  805. ExportColumn(title="装货客户", field="loadCustomer"),
  806. ExportColumn(title="送货客户", field="deliveryCustomer"),
  807. ExportColumn(title="合同", field="productContract"),
  808. ExportColumn(title="产品名称", field="productName"),
  809. ]
  810. rows = [
  811. {
  812. "id": "p1",
  813. "planNo": "PO1",
  814. "_stopKind": "load",
  815. "_deliverySeq": None,
  816. "deliveryTimes": "",
  817. "loadCustomer": "山峡",
  818. "deliveryCustomer": "",
  819. "productContract": "出库",
  820. "productName": "氯化钠",
  821. },
  822. {
  823. "id": "p1",
  824. "planNo": "PO1",
  825. "_stopKind": "delivery",
  826. "_deliverySeq": 1,
  827. "deliveryTimes": "2026-10-07",
  828. "loadCustomer": "",
  829. "deliveryCustomer": "奇创",
  830. "productContract": "HT-1",
  831. "productName": "次氯酸钠",
  832. },
  833. {
  834. "id": "p1",
  835. "planNo": "PO1",
  836. "_stopKind": "delivery",
  837. "_deliverySeq": 2,
  838. "deliveryTimes": "2026-10-08",
  839. "loadCustomer": "",
  840. "deliveryCustomer": "奇创",
  841. "productContract": "HT-2",
  842. "productName": "氯化钠",
  843. },
  844. ]
  845. workbook = Workbook()
  846. workbook.active.title = "计划列表"
  847. append_archive_list_merge_sheet(
  848. workbook,
  849. columns,
  850. rows,
  851. Resolver(),
  852. sheet_title="计划列表(合并)",
  853. plan_fields=_PLAN_LIST_PLAN_MERGE_FIELDS,
  854. delivery_fields=_PLAN_LIST_DELIVERY_MERGE_FIELDS,
  855. contract_field="productContract",
  856. load_fields=_PLAN_LIST_LOAD_COPY_FIELDS,
  857. number_plans=False,
  858. join_plan_fields=("deliveryTimes",),
  859. )
  860. sheet = workbook["计划列表(合并)"]
  861. ranges = {str(item) for item in sheet.merged_cells.ranges}
  862. assert "A2:A4" in ranges
  863. assert "B2:B4" in ranges
  864. assert "C2:C4" in ranges
  865. assert sheet["B2"].value == "2026-10-07\n2026-10-08"
  866. assert sheet["C2"].value == "山峡"
  867. assert sheet["D2"].value == "—"
  868. assert sheet["D3"].value == "奇创"
  869. assert "F2:F4" not in ranges
  870. assert sheet["F2"].value == "氯化钠"
  871. assert sheet["F4"].value == "氯化钠"