pl_plan_archive_export.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910
  1. """运输计划归档导出:按段次×产品展开(装货行与各送货段各一行)。"""
  2. from __future__ import annotations
  3. from datetime import date, datetime
  4. from decimal import Decimal
  5. from typing import Optional, Union
  6. from sqlalchemy.orm import Session
  7. from app.models.pl_models import PlPlan, PlPlanContract, PlPlanProduct, PlTransport
  8. from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead
  9. from app.services.pl.plan_list_export_segments import (
  10. format_stop_segment_label,
  11. iter_segment_product_slots,
  12. )
  13. from app.services.pl.plan_list_filters import customer_name_matches_keywords
  14. from app.services.pl_internal_customer import is_internal_customer_name
  15. from app.services.pl_plan_stop_service import load_plan_stops_grouped
  16. from app.services.pl_product_display import load_crm_product_alias_maps, resolve_product_display_names
  17. StopKind = str # "load" | "delivery"
  18. def _unique_contract_nos(*sources: Optional[str]) -> str:
  19. """按出现顺序去重拼接合同编号。"""
  20. seen: list[str] = []
  21. for raw in sources:
  22. for part in str(raw or "").replace(",", ",").split(","):
  23. no = part.strip()
  24. if no and no not in seen:
  25. seen.append(no)
  26. return "、".join(seen)
  27. def _contract_nos_from_stop_links(contracts: Optional[list]) -> list[str]:
  28. """段次合同关联 → 编号列表(去重保序)。"""
  29. seen: list[str] = []
  30. for item in contracts or []:
  31. no = (
  32. getattr(item, "contract_no", None)
  33. or (item.get("contractNo") if isinstance(item, dict) else None)
  34. or getattr(item, "contractNo", None)
  35. or ""
  36. )
  37. no = str(no or "").strip()
  38. if not no:
  39. cid = (
  40. getattr(item, "contract_id", None)
  41. or (item.get("contractId") if isinstance(item, dict) else None)
  42. or getattr(item, "contractId", None)
  43. or ""
  44. )
  45. no = str(cid or "").strip()
  46. if no and no not in seen:
  47. seen.append(no)
  48. return seen
  49. def format_stop_contract_export_label(
  50. kind: StopKind,
  51. contracts: Optional[list],
  52. customer_name: Optional[str],
  53. ) -> str:
  54. """与前端列表一致:有编号显示编号;山峡/云栋缺合同 → 装货出库 / 送货入库。"""
  55. nos = _contract_nos_from_stop_links(contracts)
  56. if nos:
  57. return "、".join(nos)
  58. if is_internal_customer_name(customer_name):
  59. return "出库" if kind == "load" else "入库"
  60. return ""
  61. def _load_contract_nos_by_plan(
  62. db: Session, tenant_no: str, plan_ids: list[str]
  63. ) -> dict[str, list[str]]:
  64. if not plan_ids:
  65. return {}
  66. rows = (
  67. db.query(PlPlanContract)
  68. .filter(
  69. PlPlanContract.tenant_no == tenant_no,
  70. PlPlanContract.del_flag == 0,
  71. PlPlanContract.plan_id.in_(plan_ids),
  72. )
  73. .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc())
  74. .all()
  75. )
  76. out: dict[str, list[str]] = {pid: [] for pid in plan_ids}
  77. for row in rows:
  78. no = (row.contract_no or "").strip()
  79. if not no:
  80. continue
  81. bucket = out.setdefault(row.plan_id, [])
  82. if no not in bucket:
  83. bucket.append(no)
  84. return out
  85. PLAN_TYPE_LABELS = {
  86. "direct": "直销单",
  87. "inbound": "入库单",
  88. "outbound": "出库单",
  89. }
  90. ARCHIVE_STATUS_LABELS = {
  91. "archived": "已归档",
  92. "unarchived": "未归档",
  93. "draft": "已暂存",
  94. }
  95. FOLLOW_STATUS_LABELS = {
  96. "drafting": "草稿中",
  97. "wait_notice": "等通知",
  98. "pending_arrange": "待安排",
  99. "arranged": "已安排",
  100. "completed": "已完成",
  101. "cancelled": "已取消",
  102. "draft": "待安排",
  103. "processing": "进行中",
  104. }
  105. FREIGHT_TYPE_LABELS = {
  106. "by_weight": "按重量",
  107. "by_trip": "按趟",
  108. }
  109. def resolve_archive_list_io_column(kind: StopKind, customer_name: Optional[str]) -> str:
  110. """装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。"""
  111. internal = is_internal_customer_name(customer_name)
  112. if kind == "load":
  113. return "outbound" if internal else "inbound"
  114. return "inbound" if internal else "outbound"
  115. def _format_date_value(value: Optional[Union[date, datetime, str]]) -> str:
  116. if value is None:
  117. return ""
  118. return str(value)[:10]
  119. def _format_plate_display(license_plate: Optional[str], trailer_no: Optional[str]) -> str:
  120. plate = (license_plate or "").strip()
  121. trailer = (trailer_no or "").strip()
  122. if not plate and not trailer:
  123. return ""
  124. if plate and trailer:
  125. return f"{plate}{trailer}"
  126. return plate or trailer
  127. def _get_stop_archive_actual_qty(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  128. return product.inbound_qty if kind == "load" else product.outbound_qty
  129. def _get_stop_archive_unit_price(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  130. return product.inbound_price if kind == "load" else product.outbound_price
  131. def _get_stop_archive_amount(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  132. stored = product.inbound_amount if kind == "load" else product.outbound_amount
  133. if stored is not None:
  134. return stored
  135. qty = _get_stop_archive_actual_qty(product, kind)
  136. price = _get_stop_archive_unit_price(product, kind)
  137. if qty is not None and price is not None:
  138. return qty * price
  139. return None
  140. def _stop_time(kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead]) -> str:
  141. if kind == "load":
  142. return _format_date_value(getattr(stop, "load_time", None))
  143. return _format_date_value(getattr(stop, "delivery_time", None))
  144. def _load_legacy_products_by_plan(
  145. db: Session, tenant_no: str, plan_ids: list[str]
  146. ) -> dict[str, list[PlPlanProduct]]:
  147. if not plan_ids:
  148. return {}
  149. products = (
  150. db.query(PlPlanProduct)
  151. .filter(
  152. PlPlanProduct.tenant_no == tenant_no,
  153. PlPlanProduct.del_flag == 0,
  154. PlPlanProduct.plan_id.in_(plan_ids),
  155. )
  156. .order_by(PlPlanProduct.order_num)
  157. .all()
  158. )
  159. grouped: dict[str, list[PlPlanProduct]] = {}
  160. for product in products:
  161. grouped.setdefault(product.plan_id, []).append(product)
  162. return grouped
  163. def _plan_common_export_fields(
  164. plan: PlPlan,
  165. transport: Optional[PlTransport],
  166. *,
  167. contract_nos: Optional[list[str]] = None,
  168. ) -> dict:
  169. freight_type_raw = transport.freight_type if transport else "by_weight"
  170. freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "")
  171. freight_price = transport.freight_price if transport else None
  172. freight_amount = plan.freight_amount
  173. if freight_amount is None and transport is not None:
  174. freight_amount = transport.freight_amount
  175. plan_date = _format_date_value(plan.plan_date)
  176. link_nos = contract_nos or []
  177. return {
  178. "planId": plan.id or "",
  179. "planNo": plan.plan_no or "",
  180. "contractNo": _unique_contract_nos(plan.contract_no, *link_nos),
  181. "planDate": plan_date,
  182. "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""),
  183. "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""),
  184. "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""),
  185. "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no),
  186. "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否",
  187. "freightType": freight_type_label,
  188. "freightPrice": freight_price,
  189. "freightAmount": freight_amount,
  190. "remark": plan.remark or "",
  191. }
  192. def _empty_pair_side_fields(*, side: str) -> dict:
  193. """side: load | delivery"""
  194. prefix = "load" if side == "load" else "delivery"
  195. return {
  196. f"{prefix}Customer": "",
  197. f"{prefix}Address": "",
  198. f"{prefix}Time": "",
  199. f"{prefix}PlanQty": None,
  200. f"{prefix}ActualQty": None,
  201. f"{prefix}InboundQty": None,
  202. f"{prefix}OutboundQty": None,
  203. f"{prefix}UnitPrice": None,
  204. f"{prefix}Amount": None,
  205. }
  206. def _empty_product_identity_fields() -> dict:
  207. return {
  208. "productName": "",
  209. "brandName": "",
  210. "aliasName": "",
  211. "model": "",
  212. "packageSpec": "",
  213. "unit": "",
  214. }
  215. def _format_export_brand_name(plan_product: Optional[PlPlanProduct]) -> str:
  216. """与表单「品牌」列一致:仅指定品牌时导出品牌名。"""
  217. if plan_product is None:
  218. return ""
  219. if (plan_product.brand_mode or "").strip() != "required":
  220. return ""
  221. return (plan_product.brand_name or "").strip()
  222. def _resolve_export_product_display(
  223. plan_product: Optional[PlPlanProduct],
  224. stop_product: Optional[PlanStopProductRead],
  225. *,
  226. standard_by_id: dict[str, str],
  227. alias_options_by_id: dict[str, list[str]],
  228. ) -> tuple[str, str]:
  229. """返回 (展示用产品名称, 别名)。"""
  230. saved_name = ""
  231. product_id = ""
  232. if plan_product is not None:
  233. saved_name = plan_product.product_name or ""
  234. product_id = plan_product.product_id or ""
  235. elif stop_product is not None:
  236. saved_name = stop_product.product_name or ""
  237. product_id = stop_product.product_id or ""
  238. display_name, alias = resolve_product_display_names(
  239. saved_product_name=saved_name,
  240. product_id=product_id,
  241. standard_by_id=standard_by_id,
  242. alias_options_by_id=alias_options_by_id,
  243. )
  244. return (display_name or saved_name or "").strip(), (alias or "").strip()
  245. def _lookup_plan_product(
  246. stop_product: PlanStopProductRead,
  247. plan_products_by_id: dict[str, PlPlanProduct],
  248. plan_products_by_product_id: dict[str, PlPlanProduct],
  249. ) -> Optional[PlPlanProduct]:
  250. plan_product_id = str(getattr(stop_product, "plan_product_id", None) or "").strip()
  251. if plan_product_id and plan_product_id in plan_products_by_id:
  252. return plan_products_by_id[plan_product_id]
  253. product_id = str(stop_product.product_id or "").strip()
  254. if product_id and product_id in plan_products_by_product_id:
  255. return plan_products_by_product_id[product_id]
  256. return None
  257. def _product_identity_from_stop(
  258. product: PlanStopProductRead,
  259. *,
  260. plan_product: Optional[PlPlanProduct] = None,
  261. standard_by_id: Optional[dict[str, str]] = None,
  262. alias_options_by_id: Optional[dict[str, list[str]]] = None,
  263. ) -> dict:
  264. display_name, alias_name = _resolve_export_product_display(
  265. plan_product,
  266. product,
  267. standard_by_id=standard_by_id or {},
  268. alias_options_by_id=alias_options_by_id or {},
  269. )
  270. return {
  271. "productName": display_name or (product.product_name or ""),
  272. "brandName": _format_export_brand_name(plan_product),
  273. "aliasName": alias_name,
  274. "model": product.model or "",
  275. "packageSpec": product.package_spec or "",
  276. "unit": product.unit or "",
  277. }
  278. def _segment_slot_customer(
  279. kind: str,
  280. load: Optional[PlanLoadRead],
  281. delivery: Optional[PlanDeliveryRead],
  282. ) -> str:
  283. if kind == "load" and load is not None:
  284. return load.customer_name or ""
  285. if kind == "delivery" and delivery is not None:
  286. return delivery.customer_name or ""
  287. return ""
  288. def _build_archive_segment_row(
  289. row_no: int,
  290. plan_common: dict,
  291. kind: str,
  292. load: Optional[PlanLoadRead],
  293. delivery: Optional[PlanDeliveryRead],
  294. delivery_seq: Optional[int],
  295. product: Optional[PlanStopProductRead],
  296. *,
  297. plan_products_by_id: dict[str, PlPlanProduct],
  298. plan_products_by_product_id: dict[str, PlPlanProduct],
  299. standard_by_id: dict[str, str],
  300. alias_options_by_id: dict[str, list[str]],
  301. ) -> dict:
  302. """一段次×一个产品一行:只填本侧客户/地址/时间/合同与本侧量价。"""
  303. fallback_contract = str(plan_common.get("contractNo") or "")
  304. row = {
  305. **plan_common,
  306. "rowNo": row_no,
  307. "_stopKind": kind,
  308. "_deliverySeq": delivery_seq,
  309. "stopSegment": format_stop_segment_label(kind, delivery_seq),
  310. "loadCustomer": "",
  311. "loadAddress": "",
  312. "loadTime": "",
  313. "deliveryCustomer": "",
  314. "deliveryAddress": "",
  315. "deliveryTime": "",
  316. "contractNo": fallback_contract,
  317. **_empty_product_identity_fields(),
  318. "planQty": None,
  319. "actualQty": None,
  320. "unitPrice": None,
  321. "amount": None,
  322. }
  323. if kind == "load" and load is not None:
  324. customer_name = load.customer_name or ""
  325. row["loadCustomer"] = customer_name
  326. row["loadAddress"] = load.address or ""
  327. row["loadTime"] = _stop_time("load", load)
  328. row["contractNo"] = format_stop_contract_export_label(
  329. "load", load.contracts, customer_name
  330. ) or fallback_contract
  331. elif kind == "delivery" and delivery is not None:
  332. customer_name = delivery.customer_name or ""
  333. row["deliveryCustomer"] = customer_name
  334. row["deliveryAddress"] = delivery.address or ""
  335. row["deliveryTime"] = _stop_time("delivery", delivery)
  336. row["contractNo"] = format_stop_contract_export_label(
  337. "delivery", delivery.contracts, customer_name
  338. ) or fallback_contract
  339. if product is not None:
  340. plan_product = _lookup_plan_product(
  341. product, plan_products_by_id, plan_products_by_product_id
  342. )
  343. row.update(
  344. _product_identity_from_stop(
  345. product,
  346. plan_product=plan_product,
  347. standard_by_id=standard_by_id,
  348. alias_options_by_id=alias_options_by_id,
  349. )
  350. )
  351. row["planQty"] = product.plan_qty
  352. row["actualQty"] = _get_stop_archive_actual_qty(product, kind)
  353. row["unitPrice"] = _get_stop_archive_unit_price(product, kind)
  354. row["amount"] = _get_stop_archive_amount(product, kind)
  355. return row
  356. def _append_legacy_plan_product_rows(
  357. rows: list[dict],
  358. row_no: int,
  359. plan: PlPlan,
  360. plan_common: dict,
  361. products: list[PlPlanProduct],
  362. *,
  363. standard_by_id: Optional[dict[str, str]] = None,
  364. alias_options_by_id: Optional[dict[str, list[str]]] = None,
  365. ) -> int:
  366. """无段表数据时回退计划产品级导出(兼容旧数据)。"""
  367. product_rows = products or [None]
  368. fallback_no = str(plan_common.get("contractNo") or "")
  369. if fallback_no:
  370. legacy_contract_no = fallback_no
  371. else:
  372. legacy_contract_no = _unique_contract_nos(
  373. format_stop_contract_export_label("load", None, plan.supplier_name),
  374. format_stop_contract_export_label(
  375. "delivery", None, plan.customer_name or plan.destination_name
  376. ),
  377. )
  378. for product in product_rows:
  379. row_no += 1
  380. row = {
  381. **plan_common,
  382. "rowNo": row_no,
  383. "_stopKind": "",
  384. "_deliverySeq": None,
  385. "stopSegment": "",
  386. "deliveryStopKind": "",
  387. **_empty_pair_side_fields(side="load"),
  388. **_empty_pair_side_fields(side="delivery"),
  389. **_empty_product_identity_fields(),
  390. "loadCustomer": plan.supplier_name or plan.customer_name or "",
  391. "loadAddress": "",
  392. "deliveryCustomer": plan.customer_name or "",
  393. "deliveryAddress": plan.destination_name or "",
  394. "deliveryTime": _format_date_value(plan.delivery_time),
  395. "contractNo": legacy_contract_no,
  396. # 兼容旧字段
  397. "stopKind": "",
  398. "stopCustomer": plan.customer_name or plan.supplier_name or "",
  399. "stopAddress": plan.destination_name or "",
  400. "stopTime": _format_date_value(plan.delivery_time),
  401. "planQty": None,
  402. "actualQty": None,
  403. "inboundQty": None,
  404. "outboundQty": None,
  405. "unitPrice": None,
  406. "amount": None,
  407. }
  408. if product is not None:
  409. display_name, alias_name = _resolve_export_product_display(
  410. product,
  411. None,
  412. standard_by_id=standard_by_id or {},
  413. alias_options_by_id=alias_options_by_id or {},
  414. )
  415. row.update(
  416. {
  417. "productName": display_name or (product.product_name or ""),
  418. "brandName": _format_export_brand_name(product),
  419. "aliasName": alias_name,
  420. "model": product.model or "",
  421. "packageSpec": product.package_spec or "",
  422. "unit": product.unit or "",
  423. "loadPlanQty": product.quantity,
  424. "loadActualQty": product.actual_qty,
  425. "loadInboundQty": product.inbound_qty,
  426. "loadOutboundQty": product.outbound_qty,
  427. "loadUnitPrice": product.inbound_price or product.outbound_price,
  428. "loadAmount": product.inbound_amount or product.outbound_amount,
  429. "planQty": product.quantity,
  430. "actualQty": product.actual_qty,
  431. "inboundQty": product.inbound_qty,
  432. "outboundQty": product.outbound_qty,
  433. "unitPrice": product.inbound_price or product.outbound_price,
  434. "amount": product.inbound_amount or product.outbound_amount,
  435. }
  436. )
  437. rows.append(row)
  438. return row_no
  439. def build_plan_archive_export_rows(
  440. db: Session,
  441. tenant_no: str,
  442. plans: list[PlPlan],
  443. transports_by_id: dict[str, PlTransport],
  444. *,
  445. stop_kind: Optional[str] = None,
  446. stop_customer_keywords: Optional[list] = None,
  447. ) -> list[dict]:
  448. """构建归档导出数据行。
  449. 默认按段次×产品展开:先装货产品行,再各送货段产品行。
  450. stop_kind 为 load / delivery 时只保留对应侧。
  451. stop_customer_keywords 非空时按该行所属侧客户过滤。
  452. 无段表数据时回退计划产品级导出,stopSegment 为空。
  453. """
  454. if not plans:
  455. return []
  456. include_load = stop_kind != "delivery"
  457. include_delivery = stop_kind != "load"
  458. keywords = [str(item).strip() for item in (stop_customer_keywords or []) if str(item).strip()]
  459. def _keep_customer(name: Optional[str]) -> bool:
  460. if not keywords:
  461. return True
  462. return customer_name_matches_keywords(name, keywords)
  463. plan_ids = [plan.id for plan in plans]
  464. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  465. legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids)
  466. contract_nos_by_plan = _load_contract_nos_by_plan(db, tenant_no, plan_ids)
  467. crm_product_ids: list[str] = []
  468. for products in legacy_products_by_plan.values():
  469. for product in products:
  470. if product.product_id:
  471. crm_product_ids.append(product.product_id)
  472. for load_read, delivery_reads in stops_by_plan.values():
  473. for stop in ([load_read] if load_read is not None else []) + list(delivery_reads or []):
  474. for stop_product in stop.products or []:
  475. if stop_product.product_id:
  476. crm_product_ids.append(stop_product.product_id)
  477. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  478. db,
  479. tenant_no=tenant_no,
  480. product_ids=crm_product_ids,
  481. )
  482. rows: list[dict] = []
  483. row_no = 0
  484. for plan in plans:
  485. transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None
  486. plan_common = _plan_common_export_fields(
  487. plan,
  488. transport,
  489. contract_nos=contract_nos_by_plan.get(plan.id, []),
  490. )
  491. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  492. plan_products = legacy_products_by_plan.get(plan.id, [])
  493. plan_products_by_id = {str(p.id): p for p in plan_products if p.id}
  494. plan_products_by_product_id: dict[str, PlPlanProduct] = {}
  495. for product in plan_products:
  496. pid = str(product.product_id or "").strip()
  497. if pid and pid not in plan_products_by_product_id:
  498. plan_products_by_product_id[pid] = product
  499. has_stops = load_read is not None or bool(delivery_reads)
  500. if not has_stops:
  501. if not include_load:
  502. continue
  503. legacy_customer = plan.customer_name or plan.supplier_name or ""
  504. if not _keep_customer(legacy_customer):
  505. continue
  506. row_no = _append_legacy_plan_product_rows(
  507. rows,
  508. row_no,
  509. plan,
  510. plan_common,
  511. plan_products,
  512. standard_by_id=standard_by_id,
  513. alias_options_by_id=alias_options_by_id,
  514. )
  515. continue
  516. for kind, slot_load, slot_delivery, seq, stop_product in iter_segment_product_slots(
  517. load_read, delivery_reads
  518. ):
  519. if kind == "load" and not include_load:
  520. continue
  521. if kind == "delivery" and not include_delivery:
  522. continue
  523. if not _keep_customer(_segment_slot_customer(kind, slot_load, slot_delivery)):
  524. continue
  525. row_no += 1
  526. rows.append(
  527. _build_archive_segment_row(
  528. row_no,
  529. plan_common,
  530. kind,
  531. slot_load,
  532. slot_delivery,
  533. seq,
  534. stop_product,
  535. plan_products_by_id=plan_products_by_id,
  536. plan_products_by_product_id=plan_products_by_product_id,
  537. standard_by_id=standard_by_id,
  538. alias_options_by_id=alias_options_by_id,
  539. )
  540. )
  541. return rows
  542. ARCHIVE_MERGED_SHEET_TITLE = "计划归档(合并)"
  543. # 与列表 PL_PLAN_SPAN_PROPS 对应:整张计划纵向合并(含装货侧)。
  544. _PLAN_LEVEL_MERGE_FIELDS = frozenset(
  545. {
  546. "rowNo",
  547. "planNo",
  548. "planDate",
  549. "archiveStatus",
  550. "followStatus",
  551. "planType",
  552. "erpEntered",
  553. "licensePlate",
  554. "freightType",
  555. "freightPrice",
  556. "freightAmount",
  557. "remark",
  558. "loadCustomer",
  559. "loadAddress",
  560. "loadTime",
  561. }
  562. )
  563. # 与列表 PL_DELIVERY_SPAN_PROPS 对应:同一送货段合并;仅一个送货段时连装货行一起合并。
  564. _DELIVERY_LEVEL_MERGE_FIELDS = frozenset(
  565. {
  566. "deliveryCustomer",
  567. "deliveryAddress",
  568. "deliveryTime",
  569. }
  570. )
  571. def _iter_equal_spans(rows: list[dict], key_fn) -> list[tuple[int, int]]:
  572. """返回需要合并的闭区间(数据行下标,不含表头)。单行不合并。"""
  573. spans: list[tuple[int, int]] = []
  574. index = 0
  575. total = len(rows)
  576. while index < total:
  577. key = key_fn(rows[index])
  578. end = index + 1
  579. while end < total and key_fn(rows[end]) == key:
  580. end += 1
  581. if end - index > 1:
  582. spans.append((index, end - 1))
  583. index = end
  584. return spans
  585. _PLAN_LIST_PLAN_MERGE_FIELDS = frozenset(
  586. {
  587. "planNo",
  588. "belongsCompany",
  589. "followStatus",
  590. "planDate",
  591. "deliveryTimes",
  592. "planType",
  593. "licensePlate",
  594. "personnelSummary",
  595. "followUserName",
  596. "archiveStatus",
  597. "erpEntered",
  598. "hazardCertReady",
  599. "loadCustomer",
  600. "loadAddressTime",
  601. "loadDibang",
  602. }
  603. )
  604. _PLAN_LIST_DELIVERY_MERGE_FIELDS = frozenset(
  605. {
  606. "deliveryCustomer",
  607. "deliveryAddressTime",
  608. "deliveryDibang",
  609. }
  610. )
  611. _PLAN_LIST_LOAD_COPY_FIELDS = ("loadCustomer", "loadAddressTime", "loadDibang")
  612. def _export_plan_group_key(row: dict) -> str:
  613. return str(row.get("planId") or row.get("id") or "")
  614. def prepare_archive_list_display_rows(
  615. rows: list[dict],
  616. *,
  617. contract_field: str = "contractNo",
  618. load_fields: tuple[str, ...] = ("loadCustomer", "loadAddress", "loadTime"),
  619. delivery_fields: Optional[frozenset] = None,
  620. number_plans: bool = True,
  621. join_plan_fields: tuple[str, ...] = (),
  622. ) -> list[dict]:
  623. """按列表合并规则整理单元格取值。
  624. 序号按计划计,一张计划一个号(归档导出)。
  625. 只有一个送货段时,送货侧字段写到该计划首行,以便和装货行合并后仍能看见。
  626. 多个送货段时,装货行的送货客户为「—」。
  627. """
  628. if delivery_fields is None:
  629. delivery_fields = _DELIVERY_LEVEL_MERGE_FIELDS
  630. prepared: list[dict] = []
  631. plan_index = 0
  632. cursor = 0
  633. while cursor < len(rows):
  634. plan_id = _export_plan_group_key(rows[cursor])
  635. end = cursor + 1
  636. while end < len(rows) and _export_plan_group_key(rows[end]) == plan_id:
  637. end += 1
  638. group = [dict(item) for item in rows[cursor:end]]
  639. plan_index += 1
  640. delivery_seqs: list[int] = []
  641. for item in group:
  642. if item.get("_stopKind") == "delivery" and item.get("_deliverySeq") is not None:
  643. seq = int(item["_deliverySeq"])
  644. if seq not in delivery_seqs:
  645. delivery_seqs.append(seq)
  646. sole_seq = delivery_seqs[0] if len(delivery_seqs) == 1 else None
  647. for item in group:
  648. kind = item.get("_stopKind") or ""
  649. if kind == "load":
  650. merge_seq = sole_seq if sole_seq is not None else 0
  651. elif kind == "delivery":
  652. merge_seq = int(item.get("_deliverySeq") or 1)
  653. else:
  654. merge_seq = 1
  655. item["_mergeDeliverySeq"] = merge_seq
  656. item["_contractMergeKey"] = (
  657. f"{kind}|{merge_seq}|{item.get(contract_field) or ''}"
  658. )
  659. if number_plans:
  660. item["rowNo"] = plan_index
  661. if sole_seq is not None:
  662. delivery_row = next((item for item in group if item.get("_stopKind") == "delivery"), None)
  663. if delivery_row is not None:
  664. for field in delivery_fields:
  665. group[0][field] = delivery_row.get(field) or ""
  666. else:
  667. for item in group:
  668. if item.get("_mergeDeliverySeq") != 0:
  669. continue
  670. if not item.get("deliveryCustomer"):
  671. item["deliveryCustomer"] = "—"
  672. load_row = next((item for item in group if item.get("_stopKind") == "load"), None)
  673. if load_row is not None:
  674. for field in load_fields:
  675. if not group[0].get(field):
  676. group[0][field] = load_row.get(field) or ""
  677. for field in join_plan_fields:
  678. seen: list[str] = []
  679. for item in group:
  680. text = str(item.get(field) or "").strip()
  681. if text and text not in seen:
  682. seen.append(text)
  683. if seen:
  684. group[0][field] = "\n".join(seen)
  685. prepared.extend(group)
  686. cursor = end
  687. return prepared
  688. def append_archive_list_merge_sheet(
  689. wb,
  690. columns,
  691. rows: list[dict],
  692. resolver,
  693. *,
  694. sheet_title: Optional[str] = None,
  695. plan_fields: Optional[frozenset] = None,
  696. delivery_fields: Optional[frozenset] = None,
  697. contract_field: str = "contractNo",
  698. load_fields: tuple[str, ...] = ("loadCustomer", "loadAddress", "loadTime"),
  699. number_plans: bool = True,
  700. join_plan_fields: tuple[str, ...] = (),
  701. ) -> None:
  702. """追加按列表合并的 sheet。默认标题为「计划归档(合并)」。"""
  703. from openpyxl.styles import Alignment
  704. if delivery_fields is None:
  705. delivery_fields = _DELIVERY_LEVEL_MERGE_FIELDS
  706. if plan_fields is None:
  707. plan_fields = _PLAN_LEVEL_MERGE_FIELDS
  708. display_rows = prepare_archive_list_display_rows(
  709. list(rows),
  710. contract_field=contract_field,
  711. load_fields=load_fields,
  712. delivery_fields=delivery_fields,
  713. number_plans=number_plans,
  714. join_plan_fields=join_plan_fields,
  715. )
  716. sheet = wb.create_sheet((sheet_title or ARCHIVE_MERGED_SHEET_TITLE)[:31])
  717. sheet.append([column.title for column in columns])
  718. for row in display_rows:
  719. sheet.append(resolver.row_to_cells(row, columns))
  720. field_column = {column.field: index + 1 for index, column in enumerate(columns)}
  721. vertical = Alignment(vertical="center", wrap_text=True)
  722. def merge_fields(fields, spans: list[tuple[int, int]]) -> None:
  723. for field in fields:
  724. column_index = field_column.get(field)
  725. if not column_index:
  726. continue
  727. for start, end in spans:
  728. sheet.merge_cells(
  729. start_row=start + 2,
  730. start_column=column_index,
  731. end_row=end + 2,
  732. end_column=column_index,
  733. )
  734. sheet.cell(start + 2, column_index).alignment = vertical
  735. plan_key = _export_plan_group_key
  736. plan_spans = _iter_equal_spans(display_rows, plan_key)
  737. merged_plan_fields = set(plan_fields)
  738. merged_plan_fields.update(field for field in field_column if str(field).startswith("attach"))
  739. merge_fields(merged_plan_fields, plan_spans)
  740. merge_fields(
  741. delivery_fields,
  742. _iter_equal_spans(
  743. display_rows,
  744. lambda row: (_export_plan_group_key(row), row.get("_mergeDeliverySeq")),
  745. ),
  746. )
  747. if contract_field in field_column:
  748. merge_fields(
  749. [contract_field],
  750. _iter_equal_spans(
  751. display_rows,
  752. lambda row: (_export_plan_group_key(row), row.get("_contractMergeKey")),
  753. ),
  754. )
  755. def export_plan_list_xlsx_response(
  756. db,
  757. *,
  758. tenant_no: str,
  759. sheet_title: str,
  760. columns,
  761. rows: list[dict],
  762. filename_stem: str,
  763. ):
  764. """运输计划 / 危化品证列表 Excel:明细页 + 按列表合并的第二页。"""
  765. from app.utils.excel_export import (
  766. DictLabelResolver,
  767. ExportValidationError,
  768. assert_export_row_count,
  769. workbook_to_streaming_response,
  770. )
  771. from openpyxl import Workbook
  772. assert_export_row_count(len(rows))
  773. if not columns:
  774. raise ExportValidationError("没有可导出的列")
  775. resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
  776. workbook = Workbook()
  777. detail = workbook.active
  778. detail.title = sheet_title[:31]
  779. detail.append([column.title for column in columns])
  780. for row in rows:
  781. detail.append(resolver.row_to_cells(row, columns))
  782. append_archive_list_merge_sheet(
  783. workbook,
  784. columns,
  785. rows,
  786. resolver,
  787. sheet_title=f"{sheet_title}(合并)",
  788. plan_fields=_PLAN_LIST_PLAN_MERGE_FIELDS,
  789. delivery_fields=_PLAN_LIST_DELIVERY_MERGE_FIELDS,
  790. contract_field="productContract",
  791. load_fields=_PLAN_LIST_LOAD_COPY_FIELDS,
  792. number_plans=False,
  793. join_plan_fields=("deliveryTimes",),
  794. )
  795. return workbook_to_streaming_response(workbook, filename_stem=filename_stem)
  796. def export_plan_archive_xlsx_response(
  797. db,
  798. *,
  799. tenant_no: str,
  800. columns,
  801. rows: list[dict],
  802. filename_stem: str,
  803. ):
  804. """归档 Excel:第一页为段次明细,第二页为列表合并样式。"""
  805. from app.utils.excel_export import (
  806. DictLabelResolver,
  807. ExportValidationError,
  808. assert_export_row_count,
  809. workbook_to_streaming_response,
  810. )
  811. from openpyxl import Workbook
  812. assert_export_row_count(len(rows))
  813. if not columns:
  814. raise ExportValidationError("没有可导出的列")
  815. resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
  816. workbook = Workbook()
  817. detail = workbook.active
  818. detail.title = "计划归档"
  819. detail.append([column.title for column in columns])
  820. for row in rows:
  821. detail.append(resolver.row_to_cells(row, columns))
  822. append_archive_list_merge_sheet(workbook, columns, rows, resolver)
  823. return workbook_to_streaming_response(workbook, filename_stem=filename_stem)