pl_plan_archive_export.py 34 KB

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