pl_io_order_service.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. """运输计划 · 入库单 / 出库单(安排运输落单、现场回传、作废)"""
  2. from __future__ import annotations
  3. import json
  4. import secrets
  5. from dataclasses import dataclass
  6. from datetime import date, datetime
  7. from decimal import Decimal
  8. from typing import Any, Optional, Union
  9. from sqlalchemy.orm import Session
  10. from app.core.config import settings
  11. from app.core.utils import generate_uuid
  12. from app.models.crm_models import CrmProduct, CrmProductBrand
  13. from app.models.db_models import DbInboundOutbound
  14. from app.models.pl_models import (
  15. PlInboundOrder,
  16. PlInboundOrderItem,
  17. PlInboundOrderItemBrandQty,
  18. PlOutboundOrder,
  19. PlOutboundOrderItem,
  20. PlOutboundOrderItemBrandQty,
  21. PlPlan,
  22. PlPlanProduct,
  23. PlPlanProductBrandQty,
  24. PlTransport,
  25. PlTransportDbLink,
  26. )
  27. from app.services.db.mappers import parse_date_str
  28. from app.services.pl_dibang_bridge_service import _product_quantity, resolve_partner_company
  29. from app.services.pl_feedback_brand_qty import (
  30. format_brand_qty_summary,
  31. persist_io_item_brand_qtys,
  32. require_brand_qtys_if_needed,
  33. )
  34. OrderHeader = Union[PlInboundOrder, PlOutboundOrder]
  35. OrderItem = Union[PlInboundOrderItem, PlOutboundOrderItem]
  36. @dataclass
  37. class IoOrderCreateResult:
  38. kind: str # inbound | outbound
  39. order_id: str
  40. order_no: str
  41. site_access_token: str
  42. site_url: str
  43. def build_io_order_site_url(kind: str, token: str) -> str:
  44. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  45. path = "inbound" if kind == "inbound" else "outbound"
  46. return f"{origin}/#/public/pl/{path}/{token}"
  47. def _normalize_plan_date(plan: PlPlan) -> date:
  48. plan_date = plan.plan_date
  49. if isinstance(plan_date, datetime):
  50. return plan_date.date()
  51. if isinstance(plan_date, str):
  52. return parse_date_str(plan_date) or date.today()
  53. if isinstance(plan_date, date):
  54. return plan_date
  55. return date.today()
  56. def _next_order_no(db: Session, tenant_no: str, prefix: str, model: type) -> str:
  57. day = datetime.now().strftime("%Y%m%d")
  58. like = f"{prefix}{day}%"
  59. last = (
  60. db.query(model.order_no)
  61. .filter(model.tenant_no == tenant_no, model.order_no.like(like), model.del_flag == 0)
  62. .order_by(model.order_no.desc())
  63. .first()
  64. )
  65. seq = 1
  66. if last and last[0] and len(last[0]) >= len(prefix) + 8 + 4:
  67. try:
  68. seq = int(last[0][-4:]) + 1
  69. except ValueError:
  70. seq = 1
  71. return f"{prefix}{day}{seq:04d}"
  72. def _dibang_qty_by_plan_product(
  73. db: Session, tenant_no: str, transport_id: str
  74. ) -> dict[str, Decimal]:
  75. links = (
  76. db.query(PlTransportDbLink)
  77. .filter(
  78. PlTransportDbLink.tenant_no == tenant_no,
  79. PlTransportDbLink.transport_id == transport_id,
  80. PlTransportDbLink.del_flag == 0,
  81. )
  82. .all()
  83. )
  84. if not links:
  85. return {}
  86. inv_ids = [x.db_inventory_id for x in links if x.db_inventory_id]
  87. if not inv_ids:
  88. return {}
  89. inv_rows = (
  90. db.query(DbInboundOutbound)
  91. .filter(DbInboundOutbound.id.in_(inv_ids), DbInboundOutbound.del_flag == 0)
  92. .all()
  93. )
  94. inv_map = {r.id: r for r in inv_rows}
  95. out: dict[str, Decimal] = {}
  96. for link in links:
  97. inv = inv_map.get(link.db_inventory_id)
  98. if not inv or inv.quantity is None:
  99. continue
  100. out[link.plan_product_id] = Decimal(str(inv.quantity))
  101. return out
  102. def create_io_order_for_transport(
  103. db: Session,
  104. *,
  105. tenant_no: str,
  106. plan: PlPlan,
  107. products: list[PlPlanProduct],
  108. transport: PlTransport,
  109. user_id: Optional[str],
  110. dept_id: str,
  111. after_dibang: bool = False,
  112. ) -> Optional[IoOrderCreateResult]:
  113. """安排运输后创建入库/出库单。直销返回 None。"""
  114. if plan.plan_type not in ("inbound", "outbound"):
  115. return None
  116. kind = plan.plan_type
  117. header_model = PlInboundOrder if kind == "inbound" else PlOutboundOrder
  118. item_model = PlInboundOrderItem if kind == "inbound" else PlOutboundOrderItem
  119. prefix = "RK" if kind == "inbound" else "CK"
  120. partner = resolve_partner_company(plan, plan.plan_type)
  121. if not partner:
  122. raise ValueError("无法解析往来单位,请检查合同供方/需方信息")
  123. token = secrets.token_urlsafe(32)[:64]
  124. order_no = _next_order_no(db, tenant_no, prefix, header_model)
  125. plan_date = _normalize_plan_date(plan)
  126. license_plate = (transport.license_plate or plan.license_plate or "").strip()
  127. dibang_map = _dibang_qty_by_plan_product(db, tenant_no, transport.id) if after_dibang else {}
  128. header = header_model(
  129. id=generate_uuid(),
  130. order_no=order_no,
  131. plan_id=plan.id,
  132. transport_id=transport.id,
  133. status="in_progress",
  134. partner_company=partner,
  135. vehicle_no=license_plate or None,
  136. plan_no=(plan.plan_no or "").strip() or None,
  137. plan_date=plan_date,
  138. site_access_token=token,
  139. create_by=user_id,
  140. create_dept_id=dept_id or "",
  141. tenant_no=tenant_no,
  142. )
  143. db.add(header)
  144. for idx, product in enumerate(products):
  145. plan_qty = Decimal(str(_product_quantity(product)))
  146. actual = dibang_map.get(product.id)
  147. qty_source = "dibang" if actual is not None else "plan"
  148. if actual is None:
  149. actual = plan_qty
  150. db.add(
  151. item_model(
  152. id=generate_uuid(),
  153. order_id=header.id,
  154. plan_product_id=product.id,
  155. product_id=product.product_id,
  156. product_name=product.product_name,
  157. specification=product.model,
  158. unit=product.unit,
  159. plan_qty=plan_qty,
  160. actual_qty=actual,
  161. qty_source=qty_source,
  162. order_num=idx + 1,
  163. create_by=user_id,
  164. create_dept_id=dept_id or "",
  165. tenant_no=tenant_no,
  166. )
  167. )
  168. db.flush()
  169. return IoOrderCreateResult(
  170. kind=kind,
  171. order_id=header.id,
  172. order_no=order_no,
  173. site_access_token=token,
  174. site_url=build_io_order_site_url(kind, token),
  175. )
  176. def apply_dibang_prefill_to_order(
  177. db: Session,
  178. *,
  179. tenant_no: str,
  180. plan: PlPlan,
  181. transport: PlTransport,
  182. ) -> None:
  183. """地磅单生成后,用数量预填尚未被现场改过的明细 actual_qty。"""
  184. if plan.plan_type not in ("inbound", "outbound"):
  185. return
  186. header_model = PlInboundOrder if plan.plan_type == "inbound" else PlOutboundOrder
  187. item_model = PlInboundOrderItem if plan.plan_type == "inbound" else PlOutboundOrderItem
  188. header = (
  189. db.query(header_model)
  190. .filter(
  191. header_model.tenant_no == tenant_no,
  192. header_model.plan_id == plan.id,
  193. header_model.transport_id == transport.id,
  194. header_model.status != "voided",
  195. header_model.del_flag == 0,
  196. )
  197. .order_by(header_model.create_time.desc())
  198. .first()
  199. )
  200. if not header:
  201. return
  202. dibang_map = _dibang_qty_by_plan_product(db, tenant_no, transport.id)
  203. if not dibang_map:
  204. return
  205. items = (
  206. db.query(item_model)
  207. .filter(item_model.order_id == header.id, item_model.del_flag == 0)
  208. .all()
  209. )
  210. for item in items:
  211. qty = dibang_map.get(item.plan_product_id)
  212. if qty is None:
  213. continue
  214. if item.qty_source == "manual":
  215. continue
  216. item.actual_qty = qty
  217. item.qty_source = "dibang"
  218. db.flush()
  219. def void_io_orders_for_plan(
  220. db: Session,
  221. *,
  222. tenant_no: str,
  223. plan_id: str,
  224. user_id: Optional[str],
  225. ) -> None:
  226. for model in (PlInboundOrder, PlOutboundOrder):
  227. rows = (
  228. db.query(model)
  229. .filter(
  230. model.tenant_no == tenant_no,
  231. model.plan_id == plan_id,
  232. model.status != "voided",
  233. model.del_flag == 0,
  234. )
  235. .all()
  236. )
  237. for row in rows:
  238. row.status = "voided"
  239. row.update_by = user_id
  240. db.flush()
  241. def get_active_io_order_for_plan(
  242. db: Session, tenant_no: str, plan: PlPlan
  243. ) -> Optional[tuple[str, OrderHeader]]:
  244. if plan.plan_type == "inbound":
  245. row = (
  246. db.query(PlInboundOrder)
  247. .filter(
  248. PlInboundOrder.tenant_no == tenant_no,
  249. PlInboundOrder.plan_id == plan.id,
  250. PlInboundOrder.del_flag == 0,
  251. )
  252. .order_by(PlInboundOrder.create_time.desc())
  253. .first()
  254. )
  255. return ("inbound", row) if row else None
  256. if plan.plan_type == "outbound":
  257. row = (
  258. db.query(PlOutboundOrder)
  259. .filter(
  260. PlOutboundOrder.tenant_no == tenant_no,
  261. PlOutboundOrder.plan_id == plan.id,
  262. PlOutboundOrder.del_flag == 0,
  263. )
  264. .order_by(PlOutboundOrder.create_time.desc())
  265. .first()
  266. )
  267. return ("outbound", row) if row else None
  268. return None
  269. def parse_feedback_images(raw: Optional[str]) -> list[str]:
  270. if not raw:
  271. return []
  272. try:
  273. parsed = json.loads(raw)
  274. if isinstance(parsed, list):
  275. return [str(item) for item in parsed if item]
  276. except (TypeError, json.JSONDecodeError):
  277. pass
  278. return [part.strip() for part in raw.split(",") if part.strip()]
  279. def dump_feedback_images(images: list[str]) -> str:
  280. return json.dumps(images, ensure_ascii=False)
  281. _VALID_SITE_FEEDBACK_STATUSES = frozenset({"in_progress", "completed", "voided"})
  282. def _io_item_brand_qty_model(kind: str):
  283. if kind == "inbound":
  284. return PlInboundOrderItemBrandQty
  285. return PlOutboundOrderItemBrandQty
  286. def update_io_order_items_qty_and_brand(
  287. db: Session,
  288. *,
  289. kind: str,
  290. tenant_no: str,
  291. order_id: str,
  292. items: list[OrderItem],
  293. item_qty: dict[str, Decimal],
  294. item_brand_qtys: Optional[dict[str, list[dict]]] = None,
  295. enforce_brand: bool = False,
  296. user_id: Optional[str] = None,
  297. ) -> None:
  298. """更新明细实际数量,并按需校验/持久化现场品牌数量。"""
  299. updating = [it for it in items if it.id in item_qty]
  300. if not updating:
  301. return
  302. plan_product_ids = [it.plan_product_id for it in updating if it.plan_product_id]
  303. products_by_id: dict[str, PlPlanProduct] = {}
  304. if plan_product_ids:
  305. rows = (
  306. db.query(PlPlanProduct)
  307. .filter(
  308. PlPlanProduct.id.in_(plan_product_ids),
  309. PlPlanProduct.tenant_no == tenant_no,
  310. PlPlanProduct.del_flag == 0,
  311. )
  312. .all()
  313. )
  314. products_by_id = {r.id: r for r in rows}
  315. brand_model = _io_item_brand_qty_model(kind)
  316. for item in updating:
  317. actual_qty = item_qty[item.id]
  318. plan_product = products_by_id.get(item.plan_product_id or "")
  319. brand_mode = plan_product.brand_mode if plan_product else None
  320. required_brand_id = plan_product.brand_id if plan_product else None
  321. brand_touched = item_brand_qtys is not None and item.id in item_brand_qtys
  322. if brand_touched:
  323. rows = item_brand_qtys[item.id]
  324. elif enforce_brand:
  325. rows = []
  326. else:
  327. rows = None
  328. if rows is not None:
  329. require_brand_qtys_if_needed(
  330. brand_mode=brand_mode,
  331. required_brand_id=required_brand_id,
  332. actual_qty=actual_qty,
  333. rows=rows,
  334. enforce=enforce_brand,
  335. )
  336. item.actual_qty = actual_qty
  337. item.qty_source = "manual"
  338. item.update_by = user_id
  339. if brand_touched:
  340. persist_io_item_brand_qtys(
  341. db,
  342. model=brand_model,
  343. tenant_no=tenant_no,
  344. order_id=order_id,
  345. order_item_id=item.id,
  346. plan_product_id=item.plan_product_id,
  347. brand_mode=brand_mode,
  348. rows=item_brand_qtys[item.id],
  349. user_id=user_id,
  350. )
  351. def apply_site_feedback(
  352. db: Session,
  353. *,
  354. kind: str,
  355. header: OrderHeader,
  356. items: list[OrderItem],
  357. item_qty: dict[str, Decimal],
  358. remark: Optional[str],
  359. images: list[str],
  360. status: str,
  361. item_brand_qtys: Optional[dict[str, list[dict]]] = None,
  362. enforce_brand: bool = False,
  363. user_id: Optional[str] = None,
  364. ) -> None:
  365. if header.status == "voided":
  366. raise ValueError("单据已作废,无法提交")
  367. if status not in _VALID_SITE_FEEDBACK_STATUSES:
  368. raise ValueError(f"无效状态: {status}")
  369. now = datetime.now()
  370. update_io_order_items_qty_and_brand(
  371. db,
  372. kind=kind,
  373. tenant_no=header.tenant_no,
  374. order_id=header.id,
  375. items=items,
  376. item_qty=item_qty,
  377. item_brand_qtys=item_brand_qtys,
  378. enforce_brand=enforce_brand,
  379. user_id=user_id,
  380. )
  381. header.site_feedback_remark = (remark or "").strip() or None
  382. header.site_feedback_images = dump_feedback_images(images) if images else header.site_feedback_images
  383. header.site_feedback_time = now
  384. if not header.site_first_submitted_at:
  385. header.site_first_submitted_at = now
  386. header.status = status
  387. header.update_by = user_id
  388. db.flush()
  389. CRM_PRODUCT_UNIT = "crm_product_unit"
  390. PLAN_TYPE_BUSINESS_LABEL = {
  391. "inbound": "入库",
  392. "outbound": "出库",
  393. "direct": "直销",
  394. }
  395. def resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
  396. code = (unit_code or "").strip()
  397. if not code:
  398. return "吨"
  399. return unit_map.get(code, code)
  400. def merge_plan_logistics_into_order(data: dict, plan: Optional[PlPlan]) -> None:
  401. if not plan:
  402. data.setdefault("businessType", None)
  403. data.setdefault("planRemark", None)
  404. data.setdefault("pickupAddress", None)
  405. data.setdefault("pickupLng", None)
  406. data.setdefault("pickupLat", None)
  407. data.setdefault("pickupTime", None)
  408. data.setdefault("pickupNotes", None)
  409. data.setdefault("destinationName", None)
  410. data.setdefault("destinationAddress", None)
  411. data.setdefault("destinationLng", None)
  412. data.setdefault("destinationLat", None)
  413. data.setdefault("deliveryTime", None)
  414. data.setdefault("destinationNotes", None)
  415. return
  416. data["businessType"] = PLAN_TYPE_BUSINESS_LABEL.get(plan.plan_type, plan.plan_type)
  417. data["planRemark"] = (plan.remark or "").strip() or None
  418. data["pickupAddress"] = plan.pickup_address
  419. data["pickupLng"] = plan.pickup_lng
  420. data["pickupLat"] = plan.pickup_lat
  421. data["pickupTime"] = plan.pickup_time
  422. data["pickupNotes"] = plan.pickup_notes
  423. data["destinationName"] = plan.destination_name
  424. data["destinationAddress"] = plan.destination_address or plan.destination_name
  425. data["destinationLng"] = plan.destination_lng
  426. data["destinationLat"] = plan.destination_lat
  427. data["deliveryTime"] = plan.delivery_time
  428. data["destinationNotes"] = plan.destination_notes
  429. def merge_plan_product_into_item(
  430. item: dict,
  431. product: Optional[PlPlanProduct],
  432. *,
  433. unit_map: dict[str, str],
  434. crm_product_no: Optional[str] = None,
  435. ) -> None:
  436. if not product:
  437. item.setdefault("productNo", None)
  438. item.setdefault("packageSpec", None)
  439. item.setdefault("deliveryMethod", None)
  440. item.setdefault("remark", None)
  441. item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit"))
  442. return
  443. # 优先 CRM 产品编码;计划冗余 product_no 可能被误存为 product_id
  444. product_id = (product.product_id or item.get("productId") or "").strip()
  445. plan_no = (product.product_no or "").strip()
  446. resolved_no = (crm_product_no or "").strip()
  447. if not resolved_no and plan_no and plan_no != product_id:
  448. resolved_no = plan_no
  449. item["productNo"] = resolved_no or None
  450. item["packageSpec"] = product.package_spec
  451. item["deliveryMethod"] = product.delivery_method
  452. item["remark"] = (product.remark or "").strip() or None
  453. if not item.get("specification") and product.model:
  454. item["specification"] = product.model
  455. item["unitLabel"] = resolve_unit_label(unit_map, item.get("unit") or product.unit)
  456. # 无品牌拆分量时,回退计划产品上的品牌快照
  457. if product.brand_name and not item.get("brandSummary"):
  458. item["brandName"] = product.brand_name
  459. def _format_io_product_summary_line(item: dict) -> str:
  460. name = (item.get("productName") or "").strip()
  461. if not name:
  462. return ""
  463. bits: list[str] = []
  464. brand = (item.get("brandSummary") or item.get("brandName") or "").strip()
  465. if brand:
  466. bits.append(f"品牌 {brand}")
  467. spec = (item.get("specification") or "").strip()
  468. if spec:
  469. bits.append(f"型号/规格 {spec}")
  470. unit = (item.get("unitLabel") or item.get("unit") or "").strip()
  471. if unit:
  472. bits.append(f"单位 {unit}")
  473. package = (item.get("packageSpec") or "").strip()
  474. if package:
  475. bits.append(f"包装规格 {package}")
  476. delivery = (item.get("deliveryMethod") or "").strip()
  477. if delivery:
  478. delivery_labels = {
  479. "self_pick": "山峡自提",
  480. "one_ticket": "一票制",
  481. "customer_pick": "客户自提",
  482. }
  483. bits.append(f"送货方式 {delivery_labels.get(delivery, delivery)}")
  484. return f"{name},{','.join(bits)}" if bits else name
  485. def enrich_io_items_with_brand_feedback(
  486. db: Session,
  487. *,
  488. kind: str,
  489. tenant_no: str,
  490. items: list[dict],
  491. products_by_id: dict[str, PlPlanProduct],
  492. prefer_site_brand_summary: bool = False,
  493. ) -> None:
  494. """读时附加品牌模式、CRM 品牌选项与现场品牌数量子表。"""
  495. if not items:
  496. return
  497. crm_product_ids: set[str] = set()
  498. order_item_ids: list[str] = []
  499. for it in items:
  500. item_id = (it.get("id") or "").strip()
  501. if item_id:
  502. order_item_ids.append(item_id)
  503. pp = products_by_id.get(it.get("planProductId") or "")
  504. product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip()
  505. if product_id:
  506. crm_product_ids.add(product_id)
  507. brand_options_by_product: dict[str, list[dict]] = {}
  508. if crm_product_ids:
  509. brand_rows = (
  510. db.query(CrmProductBrand)
  511. .filter(
  512. CrmProductBrand.product_id.in_(list(crm_product_ids)),
  513. CrmProductBrand.tenant_no == tenant_no,
  514. CrmProductBrand.status == "on",
  515. CrmProductBrand.del_flag == 0,
  516. )
  517. .order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
  518. .all()
  519. )
  520. for row in brand_rows:
  521. brand_options_by_product.setdefault(row.product_id, []).append(
  522. {"brandId": row.id, "brandName": row.brand_name}
  523. )
  524. site_rows_by_item: dict[str, list[dict]] = {}
  525. if order_item_ids:
  526. brand_model = _io_item_brand_qty_model(kind)
  527. site_rows = (
  528. db.query(brand_model)
  529. .filter(
  530. brand_model.order_item_id.in_(order_item_ids),
  531. brand_model.tenant_no == tenant_no,
  532. brand_model.del_flag == 0,
  533. )
  534. .order_by(brand_model.order_num.asc())
  535. .all()
  536. )
  537. for row in site_rows:
  538. site_rows_by_item.setdefault(row.order_item_id, []).append(
  539. {
  540. "brandId": row.brand_id or "",
  541. "brandName": row.brand_name,
  542. "qty": row.qty,
  543. }
  544. )
  545. for it in items:
  546. pp = products_by_id.get(it.get("planProductId") or "")
  547. product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip()
  548. if pp:
  549. it["brandMode"] = pp.brand_mode
  550. it["brandId"] = pp.brand_id
  551. if pp.brand_name:
  552. it["brandName"] = pp.brand_name
  553. else:
  554. it.setdefault("brandMode", None)
  555. it.setdefault("brandId", None)
  556. options = brand_options_by_product.get(product_id, [])
  557. it["brandOptions"] = options if options else None
  558. site_qtys = site_rows_by_item.get(it.get("id") or "", [])
  559. it["brandQtys"] = site_qtys if site_qtys else None
  560. if prefer_site_brand_summary:
  561. site_summary = format_brand_qty_summary(site_qtys)
  562. if site_summary:
  563. it["brandSummary"] = site_summary
  564. def enrich_io_order_dict(
  565. db: Session,
  566. data: dict,
  567. *,
  568. tenant_no: str,
  569. plan_id: Optional[str],
  570. items: list[Any] | None,
  571. public_unit_as_label: bool = False,
  572. kind: str = "inbound",
  573. ) -> dict:
  574. """读时附加计划取送货与产品扩展字段。"""
  575. from app.utils.excel_export import load_dict_label_maps
  576. plan = None
  577. if plan_id:
  578. plan = (
  579. db.query(PlPlan)
  580. .filter(
  581. PlPlan.id == plan_id,
  582. PlPlan.tenant_no == tenant_no,
  583. PlPlan.del_flag == 0,
  584. )
  585. .first()
  586. )
  587. merge_plan_logistics_into_order(data, plan)
  588. unit_map = load_dict_label_maps(
  589. db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no
  590. ).get(CRM_PRODUCT_UNIT, {})
  591. raw_items = data.get("items")
  592. if raw_items is None:
  593. return data
  594. product_ids = [
  595. it.get("planProductId")
  596. for it in raw_items
  597. if it.get("planProductId")
  598. ]
  599. products_by_id: dict[str, PlPlanProduct] = {}
  600. if product_ids:
  601. rows = (
  602. db.query(PlPlanProduct)
  603. .filter(
  604. PlPlanProduct.id.in_(product_ids),
  605. PlPlanProduct.tenant_no == tenant_no,
  606. PlPlanProduct.del_flag == 0,
  607. )
  608. .all()
  609. )
  610. products_by_id = {r.id: r for r in rows}
  611. crm_ids = {
  612. (pp.product_id or "").strip()
  613. for pp in products_by_id.values()
  614. if (pp.product_id or "").strip()
  615. }
  616. for it in raw_items:
  617. pid = (it.get("productId") or "").strip()
  618. if pid:
  619. crm_ids.add(pid)
  620. crm_no_by_id: dict[str, str] = {}
  621. if crm_ids:
  622. crm_rows = (
  623. db.query(CrmProduct.id, CrmProduct.product_no)
  624. .filter(
  625. CrmProduct.id.in_(list(crm_ids)),
  626. CrmProduct.tenant_no == tenant_no,
  627. CrmProduct.del_flag == 0,
  628. )
  629. .all()
  630. )
  631. for row in crm_rows:
  632. no = (row.product_no or "").strip()
  633. if no:
  634. crm_no_by_id[row.id] = no
  635. for it in raw_items:
  636. pp = products_by_id.get(it.get("planProductId") or "")
  637. product_id = ((pp.product_id if pp else None) or it.get("productId") or "").strip()
  638. merge_plan_product_into_item(
  639. it,
  640. pp,
  641. unit_map=unit_map,
  642. crm_product_no=crm_no_by_id.get(product_id),
  643. )
  644. if public_unit_as_label:
  645. it["unit"] = it.get("unitLabel") or resolve_unit_label(unit_map, it.get("unit"))
  646. brand_summary_by_pp: dict[str, str] = {}
  647. if product_ids:
  648. brand_rows = (
  649. db.query(PlPlanProductBrandQty)
  650. .filter(
  651. PlPlanProductBrandQty.plan_product_id.in_(product_ids),
  652. PlPlanProductBrandQty.tenant_no == tenant_no,
  653. PlPlanProductBrandQty.del_flag == 0,
  654. )
  655. .order_by(PlPlanProductBrandQty.order_num.asc())
  656. .all()
  657. )
  658. grouped: dict[str, list[str]] = {}
  659. for br in brand_rows:
  660. qty = Decimal(str(br.qty or 0))
  661. if qty <= 0:
  662. continue
  663. name = (br.brand_name or "").strip() or "未命名品牌"
  664. # 去掉多余尾零,如 6.000 -> 6
  665. qty_text = format(qty.normalize(), "f")
  666. grouped.setdefault(br.plan_product_id, []).append(f"{name} {qty_text}")
  667. brand_summary_by_pp = {k: " / ".join(v) for k, v in grouped.items()}
  668. for it in raw_items:
  669. pp_id = it.get("planProductId") or ""
  670. brand_summary = brand_summary_by_pp.get(pp_id) or None
  671. if brand_summary:
  672. it["brandSummary"] = brand_summary
  673. elif it.get("brandName"):
  674. it["brandSummary"] = it.get("brandName")
  675. else:
  676. it["brandSummary"] = None
  677. enrich_io_items_with_brand_feedback(
  678. db,
  679. kind=kind,
  680. tenant_no=tenant_no,
  681. items=raw_items,
  682. products_by_id=products_by_id,
  683. prefer_site_brand_summary=public_unit_as_label,
  684. )
  685. # 列表摘要:产品名 + 品牌/规格/单位/包装/送货方式
  686. summary_lines = [_format_io_product_summary_line(it) for it in raw_items]
  687. data["productSummary"] = ";".join(line for line in summary_lines if line)[:800]
  688. return data
  689. def order_header_to_dict(
  690. kind: str,
  691. row: OrderHeader,
  692. items: list[Any] | None = None,
  693. *,
  694. db: Session | None = None,
  695. public_unit_as_label: bool = False,
  696. ) -> dict:
  697. token = row.site_access_token or ""
  698. data = {
  699. "id": row.id,
  700. "orderNo": row.order_no,
  701. "planId": row.plan_id,
  702. "transportId": row.transport_id,
  703. "status": row.status,
  704. "partnerCompany": row.partner_company,
  705. "vehicleNo": row.vehicle_no,
  706. "planNo": row.plan_no,
  707. "planDate": row.plan_date.isoformat() if row.plan_date else None,
  708. "siteAccessToken": token,
  709. "siteUrl": build_io_order_site_url(kind, token) if token else None,
  710. "siteFeedbackRemark": row.site_feedback_remark,
  711. "siteFeedbackImages": parse_feedback_images(row.site_feedback_images),
  712. "siteFeedbackTime": row.site_feedback_time,
  713. "siteFirstSubmittedAt": row.site_first_submitted_at,
  714. "remark": row.remark,
  715. "tenantNo": row.tenant_no,
  716. "createTime": row.create_time,
  717. "updateTime": row.update_time,
  718. "kind": kind,
  719. }
  720. if items is not None:
  721. data["items"] = [
  722. {
  723. "id": it.id,
  724. "orderId": it.order_id,
  725. "planProductId": it.plan_product_id,
  726. "productId": it.product_id,
  727. "productName": it.product_name,
  728. "specification": it.specification,
  729. "unit": it.unit,
  730. "planQty": it.plan_qty,
  731. "actualQty": it.actual_qty,
  732. "qtySource": it.qty_source,
  733. }
  734. for it in items
  735. ]
  736. data["productSummary"] = "、".join(
  737. (it.product_name or "").strip() for it in items if (it.product_name or "").strip()
  738. )[:200]
  739. data["actualQtyTotal"] = float(sum(Decimal(str(it.actual_qty or 0)) for it in items))
  740. if db is not None:
  741. enrich_io_order_dict(
  742. db,
  743. data,
  744. tenant_no=row.tenant_no,
  745. plan_id=row.plan_id,
  746. items=items,
  747. public_unit_as_label=public_unit_as_label,
  748. kind=kind,
  749. )
  750. return data