pl_plan_archive_export.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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 _load_legacy_products_by_plan(
  109. db: Session, tenant_no: str, plan_ids: list[str]
  110. ) -> dict[str, list[PlPlanProduct]]:
  111. if not plan_ids:
  112. return {}
  113. products = (
  114. db.query(PlPlanProduct)
  115. .filter(
  116. PlPlanProduct.tenant_no == tenant_no,
  117. PlPlanProduct.del_flag == 0,
  118. PlPlanProduct.plan_id.in_(plan_ids),
  119. )
  120. .order_by(PlPlanProduct.order_num)
  121. .all()
  122. )
  123. grouped: dict[str, list[PlPlanProduct]] = {}
  124. for product in products:
  125. grouped.setdefault(product.plan_id, []).append(product)
  126. return grouped
  127. def _plan_common_export_fields(
  128. plan: PlPlan,
  129. transport: Optional[PlTransport],
  130. *,
  131. contract_nos: Optional[list[str]] = None,
  132. ) -> dict:
  133. freight_type_raw = transport.freight_type if transport else "by_weight"
  134. freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "")
  135. freight_price = transport.freight_price if transport else None
  136. freight_amount = plan.freight_amount
  137. if freight_amount is None and transport is not None:
  138. freight_amount = transport.freight_amount
  139. plan_date = _format_date_value(plan.plan_date)
  140. link_nos = contract_nos or []
  141. return {
  142. "planId": plan.id or "",
  143. "planNo": plan.plan_no or "",
  144. "contractNo": _unique_contract_nos(plan.contract_no, *link_nos),
  145. "planDate": plan_date,
  146. "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""),
  147. "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""),
  148. "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""),
  149. "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no),
  150. "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否",
  151. "freightType": freight_type_label,
  152. "freightPrice": freight_price,
  153. "freightAmount": freight_amount,
  154. "remark": plan.remark or "",
  155. }
  156. def _empty_product_fields() -> dict:
  157. return {
  158. "productName": "",
  159. "model": "",
  160. "packageSpec": "",
  161. "unit": "",
  162. "planQty": None,
  163. "actualQty": None,
  164. "inboundQty": None,
  165. "outboundQty": None,
  166. "unitPrice": None,
  167. "amount": None,
  168. }
  169. def _product_row_from_stop_product(
  170. product: PlanStopProductRead,
  171. kind: StopKind,
  172. customer_name: Optional[str],
  173. ) -> dict:
  174. actual_qty = _get_stop_archive_actual_qty(product, kind)
  175. unit_price = _get_stop_archive_unit_price(product, kind)
  176. amount = _get_stop_archive_amount(product, kind)
  177. io_col = resolve_archive_list_io_column(kind, customer_name)
  178. inbound_qty = actual_qty if io_col == "inbound" else None
  179. outbound_qty = actual_qty if io_col == "outbound" else None
  180. return {
  181. "productName": product.product_name or "",
  182. "model": product.model or "",
  183. "packageSpec": product.package_spec or "",
  184. "unit": product.unit or "",
  185. "planQty": product.plan_qty,
  186. "actualQty": actual_qty,
  187. "inboundQty": inbound_qty,
  188. "outboundQty": outbound_qty,
  189. "unitPrice": unit_price,
  190. "amount": amount,
  191. }
  192. def _append_stop_product_rows(
  193. rows: list[dict],
  194. row_no: int,
  195. plan_common: dict,
  196. kind: StopKind,
  197. stop: Union[PlanLoadRead, PlanDeliveryRead],
  198. delivery_seq: Optional[int],
  199. products: list[PlanStopProductRead],
  200. ) -> int:
  201. customer_name = stop.customer_name or ""
  202. stop_common = {
  203. **plan_common,
  204. "stopKind": _stop_kind_label(kind, delivery_seq),
  205. "stopCustomer": customer_name,
  206. "stopAddress": stop.address or "",
  207. "stopTime": _stop_time(kind, stop),
  208. }
  209. product_rows = products or [None]
  210. for product in product_rows:
  211. row_no += 1
  212. row = {
  213. **stop_common,
  214. "rowNo": row_no,
  215. **_empty_product_fields(),
  216. }
  217. if product is not None:
  218. row.update(_product_row_from_stop_product(product, kind, customer_name))
  219. rows.append(row)
  220. return row_no
  221. def _append_legacy_plan_product_rows(
  222. rows: list[dict],
  223. row_no: int,
  224. plan: PlPlan,
  225. plan_common: dict,
  226. products: list[PlPlanProduct],
  227. ) -> int:
  228. """无段表数据时回退计划产品级导出(兼容旧数据)。"""
  229. stop_common = {
  230. **plan_common,
  231. "stopKind": "",
  232. "stopCustomer": plan.customer_name or plan.supplier_name or "",
  233. "stopAddress": plan.destination_name or "",
  234. "stopTime": _format_date_value(plan.delivery_time),
  235. }
  236. product_rows = products or [None]
  237. for product in product_rows:
  238. row_no += 1
  239. row = {
  240. **stop_common,
  241. "rowNo": row_no,
  242. **_empty_product_fields(),
  243. }
  244. if product is not None:
  245. row.update(
  246. {
  247. "productName": product.product_name or "",
  248. "model": product.model or "",
  249. "packageSpec": product.package_spec or "",
  250. "unit": product.unit or "",
  251. "planQty": product.quantity,
  252. "actualQty": product.actual_qty,
  253. "inboundQty": product.inbound_qty,
  254. "outboundQty": product.outbound_qty,
  255. "unitPrice": product.inbound_price or product.outbound_price,
  256. "amount": product.inbound_amount or product.outbound_amount,
  257. }
  258. )
  259. rows.append(row)
  260. return row_no
  261. def build_plan_archive_export_rows(
  262. db: Session,
  263. tenant_no: str,
  264. plans: list[PlPlan],
  265. transports_by_id: dict[str, PlTransport],
  266. *,
  267. stop_kind: Optional[str] = None,
  268. stop_customer_keywords: Optional[list] = None,
  269. ) -> list[dict]:
  270. """构建归档导出数据行:每计划 × 每段次 × 每段产品一行。
  271. stop_kind 为 load / delivery 时只导出该类段次,与归档列表段次漏斗一致。
  272. stop_customer_keywords 非空时只导出客户名称包含任一关键字的段次。
  273. """
  274. if not plans:
  275. return []
  276. include_load = stop_kind != "delivery"
  277. include_delivery = stop_kind != "load"
  278. keywords = [str(item).strip() for item in (stop_customer_keywords or []) if str(item).strip()]
  279. def _keep_customer(name: Optional[str]) -> bool:
  280. if not keywords:
  281. return True
  282. return customer_name_matches_keywords(name, keywords)
  283. plan_ids = [plan.id for plan in plans]
  284. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  285. legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids)
  286. contract_nos_by_plan = _load_contract_nos_by_plan(db, tenant_no, plan_ids)
  287. rows: list[dict] = []
  288. row_no = 0
  289. for plan in plans:
  290. transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None
  291. plan_common = _plan_common_export_fields(
  292. plan,
  293. transport,
  294. contract_nos=contract_nos_by_plan.get(plan.id, []),
  295. )
  296. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  297. has_stops = load_read is not None or bool(delivery_reads)
  298. if not has_stops:
  299. if not include_load:
  300. continue
  301. legacy_customer = plan.customer_name or plan.supplier_name or ""
  302. if not _keep_customer(legacy_customer):
  303. continue
  304. row_no = _append_legacy_plan_product_rows(
  305. rows,
  306. row_no,
  307. plan,
  308. plan_common,
  309. legacy_products_by_plan.get(plan.id, []),
  310. )
  311. continue
  312. if include_load and load_read is not None and _keep_customer(load_read.customer_name):
  313. row_no = _append_stop_product_rows(
  314. rows,
  315. row_no,
  316. plan_common,
  317. "load",
  318. load_read,
  319. None,
  320. load_read.products or [],
  321. )
  322. if include_delivery:
  323. for idx, delivery_read in enumerate(delivery_reads):
  324. if not _keep_customer(delivery_read.customer_name):
  325. continue
  326. delivery_seq = delivery_read.seq or (idx + 1)
  327. row_no = _append_stop_product_rows(
  328. rows,
  329. row_no,
  330. plan_common,
  331. "delivery",
  332. delivery_read,
  333. delivery_seq,
  334. delivery_read.products or [],
  335. )
  336. return rows