pl_plan_archive_export.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  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, PlPlanProduct, PlTransport
  8. from app.schemas.pl.plan import PlanDeliveryRead, PlanLoadRead, PlanStopProductRead
  9. from app.services.pl_internal_customer import is_internal_customer_name
  10. from app.services.pl_plan_stop_service import load_plan_stops_grouped
  11. StopKind = str # "load" | "delivery"
  12. PLAN_TYPE_LABELS = {
  13. "direct": "直销单",
  14. "inbound": "入库单",
  15. "outbound": "出库单",
  16. }
  17. ARCHIVE_STATUS_LABELS = {
  18. "archived": "已归档",
  19. "unarchived": "未归档",
  20. "draft": "已暂存",
  21. }
  22. FOLLOW_STATUS_LABELS = {
  23. "wait_notice": "等通知",
  24. "pending_arrange": "待安排",
  25. "arranged": "已安排",
  26. "completed": "已完成",
  27. "cancelled": "已取消",
  28. "draft": "待安排",
  29. "processing": "进行中",
  30. }
  31. FREIGHT_TYPE_LABELS = {
  32. "by_weight": "按重量",
  33. "by_trip": "按趟",
  34. }
  35. def resolve_archive_list_io_column(kind: StopKind, customer_name: Optional[str]) -> str:
  36. """装货/送货段入库出库列方向(与前端 resolveArchiveListIoColumn 一致)。"""
  37. internal = is_internal_customer_name(customer_name)
  38. if kind == "load":
  39. return "outbound" if internal else "inbound"
  40. return "inbound" if internal else "outbound"
  41. def _format_date_value(value: Optional[Union[date, datetime, str]]) -> str:
  42. if value is None:
  43. return ""
  44. return str(value)[:10]
  45. def _format_plate_display(license_plate: Optional[str], trailer_no: Optional[str]) -> str:
  46. plate = (license_plate or "").strip()
  47. trailer = (trailer_no or "").strip()
  48. if not plate and not trailer:
  49. return ""
  50. if plate and trailer:
  51. return f"{plate}{trailer}"
  52. return plate or trailer
  53. def _get_stop_archive_actual_qty(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  54. return product.inbound_qty if kind == "load" else product.outbound_qty
  55. def _get_stop_archive_unit_price(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  56. return product.inbound_price if kind == "load" else product.outbound_price
  57. def _get_stop_archive_amount(product: PlanStopProductRead, kind: StopKind) -> Optional[Decimal]:
  58. stored = product.inbound_amount if kind == "load" else product.outbound_amount
  59. if stored is not None:
  60. return stored
  61. qty = _get_stop_archive_actual_qty(product, kind)
  62. price = _get_stop_archive_unit_price(product, kind)
  63. if qty is not None and price is not None:
  64. return qty * price
  65. return None
  66. def _stop_kind_label(kind: StopKind, delivery_seq: Optional[int] = None) -> str:
  67. if kind == "load":
  68. return "装货"
  69. return f"送货 #{delivery_seq or 1}"
  70. def _stop_time(kind: StopKind, stop: Union[PlanLoadRead, PlanDeliveryRead]) -> str:
  71. if kind == "load":
  72. return _format_date_value(getattr(stop, "load_time", None))
  73. return _format_date_value(getattr(stop, "delivery_time", None))
  74. def _load_legacy_products_by_plan(
  75. db: Session, tenant_no: str, plan_ids: list[str]
  76. ) -> dict[str, list[PlPlanProduct]]:
  77. if not plan_ids:
  78. return {}
  79. products = (
  80. db.query(PlPlanProduct)
  81. .filter(
  82. PlPlanProduct.tenant_no == tenant_no,
  83. PlPlanProduct.del_flag == 0,
  84. PlPlanProduct.plan_id.in_(plan_ids),
  85. )
  86. .order_by(PlPlanProduct.order_num)
  87. .all()
  88. )
  89. grouped: dict[str, list[PlPlanProduct]] = {}
  90. for product in products:
  91. grouped.setdefault(product.plan_id, []).append(product)
  92. return grouped
  93. def _plan_common_export_fields(
  94. plan: PlPlan,
  95. transport: Optional[PlTransport],
  96. ) -> dict:
  97. freight_type_raw = transport.freight_type if transport else "by_weight"
  98. freight_type_label = FREIGHT_TYPE_LABELS.get(freight_type_raw, freight_type_raw or "")
  99. freight_price = transport.freight_price if transport else None
  100. freight_amount = plan.freight_amount
  101. if freight_amount is None and transport is not None:
  102. freight_amount = transport.freight_amount
  103. plan_date = _format_date_value(plan.plan_date)
  104. return {
  105. "planNo": plan.plan_no or "",
  106. "planDate": plan_date,
  107. "archiveStatus": ARCHIVE_STATUS_LABELS.get(plan.archive_status or "", plan.archive_status or ""),
  108. "followStatus": FOLLOW_STATUS_LABELS.get(plan.follow_status or "", plan.follow_status or ""),
  109. "planType": PLAN_TYPE_LABELS.get(plan.plan_type or "", plan.plan_type or ""),
  110. "licensePlate": _format_plate_display(plan.license_plate, plan.trailer_no),
  111. "erpEntered": "是" if int(plan.erp_entered or 0) == 1 else "否",
  112. "freightType": freight_type_label,
  113. "freightPrice": freight_price,
  114. "freightAmount": freight_amount,
  115. "remark": plan.remark or "",
  116. }
  117. def _empty_product_fields() -> dict:
  118. return {
  119. "productName": "",
  120. "model": "",
  121. "packageSpec": "",
  122. "unit": "",
  123. "planQty": None,
  124. "actualQty": None,
  125. "inboundQty": None,
  126. "outboundQty": None,
  127. "unitPrice": None,
  128. "amount": None,
  129. }
  130. def _product_row_from_stop_product(
  131. product: PlanStopProductRead,
  132. kind: StopKind,
  133. customer_name: Optional[str],
  134. ) -> dict:
  135. actual_qty = _get_stop_archive_actual_qty(product, kind)
  136. unit_price = _get_stop_archive_unit_price(product, kind)
  137. amount = _get_stop_archive_amount(product, kind)
  138. io_col = resolve_archive_list_io_column(kind, customer_name)
  139. inbound_qty = actual_qty if io_col == "inbound" else None
  140. outbound_qty = actual_qty if io_col == "outbound" else None
  141. return {
  142. "productName": product.product_name or "",
  143. "model": product.model or "",
  144. "packageSpec": product.package_spec or "",
  145. "unit": product.unit or "",
  146. "planQty": product.plan_qty,
  147. "actualQty": actual_qty,
  148. "inboundQty": inbound_qty,
  149. "outboundQty": outbound_qty,
  150. "unitPrice": unit_price,
  151. "amount": amount,
  152. }
  153. def _append_stop_product_rows(
  154. rows: list[dict],
  155. row_no: int,
  156. plan_common: dict,
  157. kind: StopKind,
  158. stop: Union[PlanLoadRead, PlanDeliveryRead],
  159. delivery_seq: Optional[int],
  160. products: list[PlanStopProductRead],
  161. ) -> int:
  162. customer_name = stop.customer_name or ""
  163. stop_common = {
  164. **plan_common,
  165. "stopKind": _stop_kind_label(kind, delivery_seq),
  166. "stopCustomer": customer_name,
  167. "stopAddress": stop.address or "",
  168. "stopTime": _stop_time(kind, stop),
  169. }
  170. product_rows = products or [None]
  171. for product in product_rows:
  172. row_no += 1
  173. row = {
  174. **stop_common,
  175. "rowNo": row_no,
  176. **_empty_product_fields(),
  177. }
  178. if product is not None:
  179. row.update(_product_row_from_stop_product(product, kind, customer_name))
  180. rows.append(row)
  181. return row_no
  182. def _append_legacy_plan_product_rows(
  183. rows: list[dict],
  184. row_no: int,
  185. plan: PlPlan,
  186. plan_common: dict,
  187. products: list[PlPlanProduct],
  188. ) -> int:
  189. """无段表数据时回退计划产品级导出(兼容旧数据)。"""
  190. stop_common = {
  191. **plan_common,
  192. "stopKind": "",
  193. "stopCustomer": plan.customer_name or plan.supplier_name or "",
  194. "stopAddress": plan.destination_name or "",
  195. "stopTime": _format_date_value(plan.delivery_time),
  196. }
  197. product_rows = products or [None]
  198. for product in product_rows:
  199. row_no += 1
  200. row = {
  201. **stop_common,
  202. "rowNo": row_no,
  203. **_empty_product_fields(),
  204. }
  205. if product is not None:
  206. row.update(
  207. {
  208. "productName": product.product_name or "",
  209. "model": product.model or "",
  210. "packageSpec": product.package_spec or "",
  211. "unit": product.unit or "",
  212. "planQty": product.quantity,
  213. "actualQty": product.actual_qty,
  214. "inboundQty": product.inbound_qty,
  215. "outboundQty": product.outbound_qty,
  216. "unitPrice": product.inbound_price or product.outbound_price,
  217. "amount": product.inbound_amount or product.outbound_amount,
  218. }
  219. )
  220. rows.append(row)
  221. return row_no
  222. def build_plan_archive_export_rows(
  223. db: Session,
  224. tenant_no: str,
  225. plans: list[PlPlan],
  226. transports_by_id: dict[str, PlTransport],
  227. ) -> list[dict]:
  228. """构建归档导出数据行:每计划 × 每段次 × 每段产品一行。"""
  229. if not plans:
  230. return []
  231. plan_ids = [plan.id for plan in plans]
  232. stops_by_plan = load_plan_stops_grouped(db, tenant_no, plan_ids)
  233. legacy_products_by_plan = _load_legacy_products_by_plan(db, tenant_no, plan_ids)
  234. rows: list[dict] = []
  235. row_no = 0
  236. for plan in plans:
  237. transport = transports_by_id.get(plan.transport_id) if plan.transport_id else None
  238. plan_common = _plan_common_export_fields(plan, transport)
  239. load_read, delivery_reads = stops_by_plan.get(plan.id, (None, []))
  240. has_stops = load_read is not None or bool(delivery_reads)
  241. if not has_stops:
  242. row_no = _append_legacy_plan_product_rows(
  243. rows,
  244. row_no,
  245. plan,
  246. plan_common,
  247. legacy_products_by_plan.get(plan.id, []),
  248. )
  249. continue
  250. if load_read is not None:
  251. row_no = _append_stop_product_rows(
  252. rows,
  253. row_no,
  254. plan_common,
  255. "load",
  256. load_read,
  257. None,
  258. load_read.products or [],
  259. )
  260. for idx, delivery_read in enumerate(delivery_reads):
  261. delivery_seq = delivery_read.seq or (idx + 1)
  262. row_no = _append_stop_product_rows(
  263. rows,
  264. row_no,
  265. plan_common,
  266. "delivery",
  267. delivery_read,
  268. delivery_seq,
  269. delivery_read.products or [],
  270. )
  271. return rows