pl_plan_archive_export.py 34 KB

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