pl_plan_archive_export.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  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_filters import customer_name_matches_keywords
  10. from app.services.pl_internal_customer import is_internal_customer_name
  11. from app.services.pl_plan_stop_service import load_plan_stops_grouped
  12. def _unique_contract_nos(*sources: Optional[str]) -> str:
  13. """按出现顺序去重拼接合同编号。"""
  14. seen: list[str] = []
  15. for raw in sources:
  16. for part in str(raw or "").replace(",", ",").split(","):
  17. no = part.strip()
  18. if no and no not in seen:
  19. seen.append(no)
  20. return "、".join(seen)
  21. def _load_contract_nos_by_plan(
  22. db: Session, tenant_no: str, plan_ids: list[str]
  23. ) -> dict[str, list[str]]:
  24. if not plan_ids:
  25. return {}
  26. rows = (
  27. db.query(PlPlanContract)
  28. .filter(
  29. PlPlanContract.tenant_no == tenant_no,
  30. PlPlanContract.del_flag == 0,
  31. PlPlanContract.plan_id.in_(plan_ids),
  32. )
  33. .order_by(PlPlanContract.order_num.asc(), PlPlanContract.create_time.asc())
  34. .all()
  35. )
  36. out: dict[str, list[str]] = {pid: [] for pid in plan_ids}
  37. for row in rows:
  38. no = (row.contract_no or "").strip()
  39. if not no:
  40. continue
  41. bucket = out.setdefault(row.plan_id, [])
  42. if no not in bucket:
  43. bucket.append(no)
  44. return out
  45. StopKind = str # "load" | "delivery"
  46. PLAN_TYPE_LABELS = {
  47. "direct": "直销单",
  48. "inbound": "入库单",
  49. "outbound": "出库单",
  50. }
  51. ARCHIVE_STATUS_LABELS = {
  52. "archived": "已归档",
  53. "unarchived": "未归档",
  54. "draft": "已暂存",
  55. }
  56. FOLLOW_STATUS_LABELS = {
  57. "wait_notice": "等通知",
  58. "pending_arrange": "待安排",
  59. "arranged": "已安排",
  60. "completed": "已完成",
  61. "cancelled": "已取消",
  62. "draft": "待安排",
  63. "processing": "进行中",
  64. }
  65. FREIGHT_TYPE_LABELS = {
  66. "by_weight": "按重量",
  67. "by_trip": "按趟",
  68. }
  69. def resolve_archive_list_io_column(kind: StopKind, customer_name: Optional[str]) -> str:
  70. """装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。"""
  71. internal = is_internal_customer_name(customer_name)
  72. if kind == "load":
  73. return "outbound" if internal else "inbound"
  74. return "inbound" if internal else "outbound"
  75. def _format_date_value(value: Optional[Union[date, datetime, str]]) -> str:
  76. if value is None:
  77. return ""
  78. return str(value)[:10]
  79. def _format_plate_display(license_plate: Optional[str], trailer_no: Optional[str]) -> str:
  80. plate = (license_plate or "").strip()
  81. trailer = (trailer_no or "").strip()
  82. if not plate and not trailer:
  83. return ""
  84. if plate and trailer:
  85. return f"{plate}{trailer}"
  86. return plate or trailer
  87. def _get_stop_archive_actual_qty(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  88. return product.inbound_qty if kind == "load" else product.outbound_qty
  89. def _get_stop_archive_unit_price(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  90. return product.inbound_price if kind == "load" else product.outbound_price
  91. def _get_stop_archive_amount(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  92. stored = product.inbound_amount if kind == "load" else product.outbound_amount
  93. if stored is not None:
  94. return stored
  95. qty = _get_stop_archive_actual_qty(product, kind)
  96. price = _get_stop_archive_unit_price(product, kind)
  97. if qty is not None and price is not None:
  98. return qty * price
  99. return None
  100. def _stop_kind_label(kind: StopKind, delivery_seq: Optional[int] = None) -> str:
  101. if kind == "load":
  102. return "装货"
  103. return f"送货 #{delivery_seq or 1}"
  104. def _stop_time(kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead]) -> str:
  105. if kind == "load":
  106. return _format_date_value(getattr(stop, "load_time", None))
  107. return _format_date_value(getattr(stop, "delivery_time", None))
  108. def _product_match_key(product: PlanStopProductRead) -> tuple[str, str]:
  109. return (
  110. str(product.product_id or "").strip(),
  111. str(product.package_spec or "").strip(),
  112. )
  113. def _load_legacy_products_by_plan(
  114. db: Session, tenant_no: str, plan_ids: list[str]
  115. ) -> dict[str, list[PlPlanProduct]]:
  116. if not plan_ids:
  117. return {}
  118. products = (
  119. db.query(PlPlanProduct)
  120. .filter(
  121. PlPlanProduct.tenant_no == tenant_no,
  122. PlPlanProduct.del_flag == 0,
  123. PlPlanProduct.plan_id.in_(plan_ids),
  124. )
  125. .order_by(PlPlanProduct.order_num)
  126. .all()
  127. )
  128. grouped: dict[str, list[PlPlanProduct]] = {}
  129. for product in products:
  130. grouped.setdefault(product.plan_id, []).append(product)
  131. return grouped
  132. def _plan_common_export_fields(
  133. plan: PlPlan,
  134. transport: Optional[PlTransport],
  135. *,
  136. contract_nos: Optional[list[str]] = None,
  137. ) -> dict:
  138. freight_type_raw = transport.freight_type if transport else "by_weight"
  139. freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "")
  140. freight_price = transport.freight_price if transport else None
  141. freight_amount = plan.freight_amount
  142. if freight_amount is None and transport is not None:
  143. freight_amount = transport.freight_amount
  144. plan_date = _format_date_value(plan.plan_date)
  145. link_nos = contract_nos or []
  146. return {
  147. "planId": plan.id or "",
  148. "planNo": plan.plan_no or "",
  149. "contractNo": _unique_contract_nos(plan.contract_no, *link_nos),
  150. "planDate": plan_date,
  151. "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""),
  152. "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""),
  153. "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""),
  154. "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no),
  155. "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否",
  156. "freightType": freight_type_label,
  157. "freightPrice": freight_price,
  158. "freightAmount": freight_amount,
  159. "remark": plan.remark or "",
  160. }
  161. def _empty_pair_side_fields(*, side: str) -> dict:
  162. """side: load | delivery"""
  163. prefix = "load" if side == "load" else "delivery"
  164. return {
  165. f"{prefix}Customer": "",
  166. f"{prefix}Address": "",
  167. f"{prefix}Time": "",
  168. f"{prefix}PlanQty": None,
  169. f"{prefix}ActualQty": None,
  170. f"{prefix}InboundQty": None,
  171. f"{prefix}OutboundQty": None,
  172. f"{prefix}UnitPrice": None,
  173. f"{prefix}Amount": None,
  174. }
  175. def _empty_product_identity_fields() -> dict:
  176. return {
  177. "productName": "",
  178. "model": "",
  179. "packageSpec": "",
  180. "unit": "",
  181. }
  182. def _side_qty_fields(
  183. product: PlanStopProductRead,
  184. kind: StopKind,
  185. customer_name: Optional[str],
  186. *,
  187. side: str,
  188. ) -> dict:
  189. prefix = "load" if side == "load" else "delivery"
  190. actual_qty = _get_stop_archive_actual_qty(product, kind)
  191. unit_price = _get_stop_archive_unit_price(product, kind)
  192. amount = _get_stop_archive_amount(product, kind)
  193. io_col = resolve_archive_list_io_column(kind, customer_name)
  194. inbound_qty = actual_qty if io_col == "inbound" else None
  195. outbound_qty = actual_qty if io_col == "outbound" else None
  196. return {
  197. f"{prefix}PlanQty": product.plan_qty,
  198. f"{prefix}ActualQty": actual_qty,
  199. f"{prefix}InboundQty": inbound_qty,
  200. f"{prefix}OutboundQty": outbound_qty,
  201. f"{prefix}UnitPrice": unit_price,
  202. f"{prefix}Amount": amount,
  203. }
  204. def _product_identity_from_stop(product: PlanStopProductRead) -> dict:
  205. return {
  206. "productName": product.product_name or "",
  207. "model": product.model or "",
  208. "packageSpec": product.package_spec or "",
  209. "unit": product.unit or "",
  210. }
  211. def _append_stop_product_rows(
  212. rows: list[dict],
  213. row_no: int,
  214. plan_common: dict,
  215. kind: StopKind,
  216. stop: Union[PlanLoadRead, PlanDeliveryRead],
  217. delivery_seq: Optional[int],
  218. products: list[PlanStopProductRead],
  219. ) -> int:
  220. """单侧筛选回退:仍按段×产品一行,但写入配对列结构(另一侧留空)。"""
  221. customer_name = stop.customer_name or ""
  222. side = "load" if kind == "load" else "delivery"
  223. product_rows = products or [None]
  224. for product in product_rows:
  225. row_no += 1
  226. row = {
  227. **plan_common,
  228. "rowNo": row_no,
  229. "deliveryStopKind": _stop_kind_label(kind, delivery_seq) if kind == "delivery" else "",
  230. **_empty_pair_side_fields(side="load"),
  231. **_empty_pair_side_fields(side="delivery"),
  232. **_empty_product_identity_fields(),
  233. }
  234. row[f"{side}Customer"] = customer_name
  235. row[f"{side}Address"] = stop.address or ""
  236. row[f"{side}Time"] = _stop_time(kind, stop)
  237. # 兼容旧测试/调用方字段名
  238. row["stopKind"] = _stop_kind_label(kind, delivery_seq)
  239. row["stopCustomer"] = customer_name
  240. row["stopAddress"] = stop.address or ""
  241. row["stopTime"] = _stop_time(kind, stop)
  242. if product is not None:
  243. row.update(_product_identity_from_stop(product))
  244. side_fields = _side_qty_fields(product, kind, customer_name, side=side)
  245. row.update(side_fields)
  246. # 兼容旧单侧字段
  247. row["planQty"] = side_fields[f"{side}PlanQty"]
  248. row["actualQty"] = side_fields[f"{side}ActualQty"]
  249. row["inboundQty"] = side_fields[f"{side}InboundQty"]
  250. row["outboundQty"] = side_fields[f"{side}OutboundQty"]
  251. row["unitPrice"] = side_fields[f"{side}UnitPrice"]
  252. row["amount"] = side_fields[f"{side}Amount"]
  253. else:
  254. row.update(
  255. {
  256. "planQty": None,
  257. "actualQty": None,
  258. "inboundQty": None,
  259. "outboundQty": None,
  260. "unitPrice": None,
  261. "amount": None,
  262. }
  263. )
  264. rows.append(row)
  265. return row_no
  266. def _append_paired_product_rows(
  267. rows: list[dict],
  268. row_no: int,
  269. plan_common: dict,
  270. load: PlanLoadRead,
  271. delivery: PlanDeliveryRead,
  272. delivery_seq: int,
  273. ) -> int:
  274. """装货×送货按 productId+packageSpec 内连接,一产品一行。"""
  275. load_customer = load.customer_name or ""
  276. delivery_customer = delivery.customer_name or ""
  277. load_by_key: dict[tuple[str, str], PlanStopProductRead] = {}
  278. for product in load.products or []:
  279. key = _product_match_key(product)
  280. if not key[0]:
  281. continue
  282. # 同键多条时保留首条
  283. load_by_key.setdefault(key, product)
  284. for product_d in delivery.products or []:
  285. key = _product_match_key(product_d)
  286. if not key[0]:
  287. continue
  288. product_l = load_by_key.get(key)
  289. if product_l is None:
  290. continue
  291. row_no += 1
  292. load_fields = _side_qty_fields(product_l, "load", load_customer, side="load")
  293. delivery_fields = _side_qty_fields(
  294. product_d, "delivery", delivery_customer, side="delivery"
  295. )
  296. row = {
  297. **plan_common,
  298. "rowNo": row_no,
  299. "deliveryStopKind": _stop_kind_label("delivery", delivery_seq),
  300. "loadCustomer": load_customer,
  301. "loadAddress": load.address or "",
  302. "loadTime": _stop_time("load", load),
  303. "deliveryCustomer": delivery_customer,
  304. "deliveryAddress": delivery.address or "",
  305. "deliveryTime": _stop_time("delivery", delivery),
  306. **_product_identity_from_stop(product_l),
  307. **load_fields,
  308. **delivery_fields,
  309. }
  310. rows.append(row)
  311. return row_no
  312. def _append_legacy_plan_product_rows(
  313. rows: list[dict],
  314. row_no: int,
  315. plan: PlPlan,
  316. plan_common: dict,
  317. products: list[PlPlanProduct],
  318. ) -> int:
  319. """无段表数据时回退计划产品级导出(兼容旧数据)。"""
  320. product_rows = products or [None]
  321. for product in product_rows:
  322. row_no += 1
  323. row = {
  324. **plan_common,
  325. "rowNo": row_no,
  326. "deliveryStopKind": "",
  327. **_empty_pair_side_fields(side="load"),
  328. **_empty_pair_side_fields(side="delivery"),
  329. **_empty_product_identity_fields(),
  330. "loadCustomer": plan.supplier_name or plan.customer_name or "",
  331. "loadAddress": "",
  332. "deliveryCustomer": plan.customer_name or "",
  333. "deliveryAddress": plan.destination_name or "",
  334. "deliveryTime": _format_date_value(plan.delivery_time),
  335. # 兼容旧字段
  336. "stopKind": "",
  337. "stopCustomer": plan.customer_name or plan.supplier_name or "",
  338. "stopAddress": plan.destination_name or "",
  339. "stopTime": _format_date_value(plan.delivery_time),
  340. "planQty": None,
  341. "actualQty": None,
  342. "inboundQty": None,
  343. "outboundQty": None,
  344. "unitPrice": None,
  345. "amount": None,
  346. }
  347. if product is not None:
  348. row.update(
  349. {
  350. "productName": product.product_name or "",
  351. "model": product.model or "",
  352. "packageSpec": product.package_spec or "",
  353. "unit": product.unit or "",
  354. "loadPlanQty": product.quantity,
  355. "loadActualQty": product.actual_qty,
  356. "loadInboundQty": product.inbound_qty,
  357. "loadOutboundQty": product.outbound_qty,
  358. "loadUnitPrice": product.inbound_price or product.outbound_price,
  359. "loadAmount": product.inbound_amount or product.outbound_amount,
  360. "planQty": product.quantity,
  361. "actualQty": product.actual_qty,
  362. "inboundQty": product.inbound_qty,
  363. "outboundQty": product.outbound_qty,
  364. "unitPrice": product.inbound_price or product.outbound_price,
  365. "amount": product.inbound_amount or product.outbound_amount,
  366. }
  367. )
  368. rows.append(row)
  369. return row_no
  370. def build_plan_archive_export_rows(
  371. db: Session,
  372. tenant_no: str,
  373. plans: list[PlPlan],
  374. transports_by_id: dict[str, PlTransport],
  375. *,
  376. stop_kind: Optional[str] = None,
  377. stop_customer_keywords: Optional[list] = None,
  378. ) -> list[dict]:
  379. """构建归档导出数据行。
  380. 默认:装货×送货按产品内连接,一产品配对一行。
  381. stop_kind 为 load / delivery 时回退为单侧段×产品一行(列写入对应侧)。
  382. stop_customer_keywords 非空时:配对模式下装或送任一匹配即保留;单侧模式仅匹配该侧客户。
  383. """
  384. if not plans:
  385. return []
  386. include_load = stop_kind != "delivery"
  387. include_delivery = stop_kind != "load"
  388. pair_mode = include_load and include_delivery
  389. keywords = [str(item).strip() for item in (stop_customer_keywords or []) if str(item).strip()]
  390. def _keep_customer(name: Optional[str]) -> bool:
  391. if not keywords:
  392. return True
  393. return customer_name_matches_keywords(name, keywords)
  394. plan_ids = [plan.id for plan in plans]
  395. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  396. legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids)
  397. contract_nos_by_plan = _load_contract_nos_by_plan(db, tenant_no, plan_ids)
  398. rows: list[dict] = []
  399. row_no = 0
  400. for plan in plans:
  401. transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None
  402. plan_common = _plan_common_export_fields(
  403. plan,
  404. transport,
  405. contract_nos=contract_nos_by_plan.get(plan.id, []),
  406. )
  407. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  408. has_stops = load_read is not None or bool(delivery_reads)
  409. if not has_stops:
  410. if not include_load:
  411. continue
  412. legacy_customer = plan.customer_name or plan.supplier_name or ""
  413. if not _keep_customer(legacy_customer):
  414. continue
  415. row_no = _append_legacy_plan_product_rows(
  416. rows,
  417. row_no,
  418. plan,
  419. plan_common,
  420. legacy_products_by_plan.get(plan.id, []),
  421. )
  422. continue
  423. if pair_mode:
  424. if load_read is None:
  425. continue
  426. for idx, delivery_read in enumerate(delivery_reads):
  427. if keywords and not (
  428. _keep_customer(load_read.customer_name)
  429. or _keep_customer(delivery_read.customer_name)
  430. ):
  431. continue
  432. delivery_seq = delivery_read.seq or (idx + 1)
  433. row_no = _append_paired_product_rows(
  434. rows,
  435. row_no,
  436. plan_common,
  437. load_read,
  438. delivery_read,
  439. delivery_seq,
  440. )
  441. continue
  442. # 单侧筛选:旧逻辑
  443. if include_load and load_read is not None and _keep_customer(load_read.customer_name):
  444. row_no = _append_stop_product_rows(
  445. rows,
  446. row_no,
  447. plan_common,
  448. "load",
  449. load_read,
  450. None,
  451. load_read.products or [],
  452. )
  453. if include_delivery:
  454. for idx, delivery_read in enumerate(delivery_reads):
  455. if not _keep_customer(delivery_read.customer_name):
  456. continue
  457. delivery_seq = delivery_read.seq or (idx + 1)
  458. row_no = _append_stop_product_rows(
  459. rows,
  460. row_no,
  461. plan_common,
  462. "delivery",
  463. delivery_read,
  464. delivery_seq,
  465. delivery_read.products or [],
  466. )
  467. return rows