public_io_order.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. """运输计划 · 入库/出库单现场免登录公共 API"""
  2. from __future__ import annotations
  3. import os
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from typing import Optional
  7. from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
  8. from sqlalchemy import and_, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.attachment_biz import resolve_upload_folder
  11. from app.core.config import settings
  12. from app.core.database import get_db
  13. from app.core.response import error_response, success_response
  14. from app.core.storage import storage
  15. from app.crud import oss as oss_svc
  16. from app.models.pl_models import (
  17. PlInboundOrder,
  18. PlInboundOrderItem,
  19. PlOutboundOrder,
  20. PlOutboundOrderItem,
  21. PlPlan,
  22. PlPlanProduct,
  23. )
  24. from app.schemas.pl.io_order import (
  25. PublicIoOrderFeedback,
  26. PublicIoOrderHistoryPageRead,
  27. PublicIoOrderHistoryProductRead,
  28. PublicIoOrderHistoryRecordRead,
  29. )
  30. from app.services.pl_dibang_bridge_service import plan_has_load_stop
  31. from app.services.pl_io_order_service import (
  32. apply_io_order_stop_site_feedbacks,
  33. apply_site_feedback,
  34. build_io_order_site_url,
  35. order_header_to_dict,
  36. parse_feedback_images,
  37. )
  38. from app.services.pl_transport_stop_feedback_service import (
  39. build_public_stop_reads_for_segments,
  40. load_io_order_site_stop_segments,
  41. )
  42. from app.services.pl_product_display import (
  43. load_crm_product_alias_maps,
  44. resolve_product_display_names,
  45. )
  46. from app.utils.excel_export import load_dict_label_maps
  47. inbound_public_router = APIRouter(prefix="/public/pl/inbound", tags=["运输计划-入库单公共"])
  48. outbound_public_router = APIRouter(prefix="/public/pl/outbound", tags=["运输计划-出库单公共"])
  49. CRM_PRODUCT_UNIT = "crm_product_unit"
  50. def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
  51. code = (unit_code or "").strip()
  52. if not code:
  53. return "吨"
  54. return unit_map.get(code, code)
  55. def _models(kind: str):
  56. if kind == "inbound":
  57. return PlInboundOrder, PlInboundOrderItem
  58. return PlOutboundOrder, PlOutboundOrderItem
  59. def _get_by_token(db: Session, kind: str, token: str):
  60. header_m, item_m = _models(kind)
  61. header = (
  62. db.query(header_m)
  63. .filter(header_m.site_access_token == token, header_m.del_flag == 0)
  64. .first()
  65. )
  66. if not header:
  67. raise HTTPException(status_code=404, detail="链接无效或已失效")
  68. items = (
  69. db.query(item_m)
  70. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  71. .order_by(item_m.order_num.asc())
  72. .all()
  73. )
  74. return header, items
  75. def _resolve_io_order_link_flags(plan: Optional[PlPlan], header) -> tuple[bool, bool]:
  76. """返回 (作废, 归档)。现场链接实际走入库/出库单公共页。"""
  77. voided = header.status == "voided" or bool(plan and plan.follow_status == "cancelled")
  78. archived = bool(plan and plan.archive_status == "archived")
  79. return voided, archived
  80. def _parse_history_date(raw: Optional[str]) -> Optional[date]:
  81. text = (raw or "").strip()
  82. if not text:
  83. return None
  84. try:
  85. return date.fromisoformat(text)
  86. except ValueError:
  87. return None
  88. def _history_status_label(voided: bool, archived: bool, status: Optional[str]) -> str:
  89. if voided or status == "voided":
  90. return "已作废"
  91. if archived:
  92. return "已归档"
  93. if status == "completed":
  94. return "已完成"
  95. return "进行中"
  96. def _query_kind_history(
  97. db: Session,
  98. *,
  99. kind: str,
  100. tenant_no: str,
  101. day: date,
  102. keyword: str,
  103. ) -> list[tuple]:
  104. """返回 [(create_time, PublicIoOrderHistoryRecordRead), ...]。"""
  105. header_m, item_m = _models(kind)
  106. query = (
  107. db.query(header_m, PlPlan)
  108. .outerjoin(
  109. PlPlan,
  110. and_(PlPlan.id == header_m.plan_id, PlPlan.del_flag == 0),
  111. )
  112. .filter(
  113. header_m.tenant_no == tenant_no,
  114. header_m.del_flag == 0,
  115. header_m.plan_date == day,
  116. )
  117. )
  118. kw = (keyword or "").strip()
  119. if kw:
  120. like = f"%{kw}%"
  121. product_match = (
  122. db.query(item_m.id)
  123. .filter(
  124. item_m.order_id == header_m.id,
  125. item_m.del_flag == 0,
  126. item_m.product_name.like(like),
  127. )
  128. .exists()
  129. )
  130. query = query.filter(
  131. or_(
  132. header_m.vehicle_no.like(like),
  133. product_match,
  134. )
  135. )
  136. rows = query.order_by(header_m.create_time.desc()).all()
  137. out: list[tuple] = []
  138. for header, plan in rows:
  139. voided, archived = _resolve_io_order_link_flags(plan, header)
  140. tok = (header.site_access_token or "").strip()
  141. out.append(
  142. (
  143. header.create_time,
  144. PublicIoOrderHistoryRecordRead(
  145. kind=kind,
  146. order_id=header.id,
  147. order_no=header.order_no or "",
  148. plan_id=header.plan_id,
  149. plan_no=header.plan_no or (plan.plan_no if plan else None),
  150. plan_date=header.plan_date,
  151. partner_company=header.partner_company,
  152. supplier_name=plan.supplier_name if plan else None,
  153. customer_name=plan.customer_name if plan else None,
  154. vehicle_no=header.vehicle_no,
  155. pickup_time=plan.pickup_time if plan else None,
  156. delivery_time=plan.delivery_time if plan else None,
  157. pickup_address=plan.pickup_address if plan else None,
  158. destination_address=(
  159. (plan.destination_address or plan.destination_name) if plan else None
  160. ),
  161. site_access_token=tok or None,
  162. site_url=build_io_order_site_url(kind, tok) if tok else None,
  163. status=header.status,
  164. order_voided=voided,
  165. plan_archived=archived,
  166. status_label=_history_status_label(voided, archived, header.status),
  167. ),
  168. )
  169. )
  170. return out
  171. def _build_history_page(
  172. db: Session,
  173. *,
  174. entry_kind: str,
  175. token: str,
  176. date_raw: str,
  177. keyword: Optional[str],
  178. page: int = 1,
  179. page_size: int = 20,
  180. ) -> PublicIoOrderHistoryPageRead:
  181. header, _items = _get_by_token(db, entry_kind, token)
  182. day = _parse_history_date(date_raw)
  183. if day is None:
  184. raise HTTPException(status_code=400, detail="日期格式无效,请使用 YYYY-MM-DD")
  185. kw = (keyword or "").strip()
  186. merged = _query_kind_history(
  187. db, kind="inbound", tenant_no=header.tenant_no, day=day, keyword=kw
  188. ) + _query_kind_history(
  189. db, kind="outbound", tenant_no=header.tenant_no, day=day, keyword=kw
  190. )
  191. merged.sort(key=lambda x: x[0] or datetime.min, reverse=True)
  192. total = len(merged)
  193. page = max(1, int(page or 1))
  194. page_size = min(max(1, int(page_size or 20)), 500)
  195. start = (page - 1) * page_size
  196. page_rows = merged[start : start + page_size]
  197. records = [r for _, r in page_rows]
  198. _attach_history_products(db, tenant_no=header.tenant_no, records=records)
  199. return PublicIoOrderHistoryPageRead(
  200. date=day.isoformat(),
  201. keyword=kw or None,
  202. page=page,
  203. page_size=page_size,
  204. total=total,
  205. records=records,
  206. )
  207. def _attach_history_products(
  208. db: Session,
  209. *,
  210. tenant_no: str,
  211. records: list[PublicIoOrderHistoryRecordRead],
  212. ) -> None:
  213. if not records:
  214. return
  215. inbound_ids = [r.order_id for r in records if r.kind == "inbound"]
  216. outbound_ids = [r.order_id for r in records if r.kind == "outbound"]
  217. items_by_order: dict[str, list] = {}
  218. if inbound_ids:
  219. for item in (
  220. db.query(PlInboundOrderItem)
  221. .filter(
  222. PlInboundOrderItem.order_id.in_(inbound_ids),
  223. PlInboundOrderItem.del_flag == 0,
  224. )
  225. .order_by(PlInboundOrderItem.order_num.asc(), PlInboundOrderItem.create_time.asc())
  226. .all()
  227. ):
  228. items_by_order.setdefault(item.order_id, []).append(item)
  229. if outbound_ids:
  230. for item in (
  231. db.query(PlOutboundOrderItem)
  232. .filter(
  233. PlOutboundOrderItem.order_id.in_(outbound_ids),
  234. PlOutboundOrderItem.del_flag == 0,
  235. )
  236. .order_by(PlOutboundOrderItem.order_num.asc(), PlOutboundOrderItem.create_time.asc())
  237. .all()
  238. ):
  239. items_by_order.setdefault(item.order_id, []).append(item)
  240. all_items = [it for items in items_by_order.values() for it in items]
  241. plan_product_ids = {
  242. (it.plan_product_id or "").strip() for it in all_items if (it.plan_product_id or "").strip()
  243. }
  244. plan_product_by_id: dict[str, PlPlanProduct] = {}
  245. if plan_product_ids:
  246. for prod in (
  247. db.query(PlPlanProduct)
  248. .filter(PlPlanProduct.id.in_(list(plan_product_ids)), PlPlanProduct.del_flag == 0)
  249. .all()
  250. ):
  251. plan_product_by_id[prod.id] = prod
  252. crm_product_ids = {
  253. (it.product_id or "").strip() for it in all_items if (it.product_id or "").strip()
  254. }
  255. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  256. db, tenant_no=tenant_no, product_ids=crm_product_ids
  257. )
  258. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  259. CRM_PRODUCT_UNIT, {}
  260. )
  261. for record in records:
  262. products: list[PublicIoOrderHistoryProductRead] = []
  263. for item in items_by_order.get(record.order_id) or []:
  264. product_id = (item.product_id or "").strip()
  265. plan_product = plan_product_by_id.get((item.plan_product_id or "").strip())
  266. display_name, _alias = resolve_product_display_names(
  267. saved_product_name=item.product_name,
  268. product_id=product_id,
  269. standard_by_id=standard_by_id,
  270. alias_options_by_id=alias_options_by_id,
  271. )
  272. model = (item.specification or "").strip() or (
  273. (plan_product.model or "").strip() if plan_product else ""
  274. ) or None
  275. package_spec = (
  276. (plan_product.package_spec or "").strip() if plan_product else ""
  277. ) or None
  278. qty = item.actual_qty if item.actual_qty is not None else item.plan_qty
  279. products.append(
  280. PublicIoOrderHistoryProductRead(
  281. product_name=display_name or item.product_name or "—",
  282. model=model,
  283. package_spec=package_spec,
  284. quantity=qty,
  285. unit=_resolve_unit_label(unit_map, item.unit),
  286. )
  287. )
  288. record.products = products
  289. def _build_page(db: Session, kind: str, header, items) -> dict:
  290. plan = db.query(PlPlan).filter(PlPlan.id == header.plan_id, PlPlan.del_flag == 0).first()
  291. voided, archived = _resolve_io_order_link_flags(plan, header)
  292. data = order_header_to_dict(
  293. kind, header, items, db=db, public_unit_as_label=True
  294. )
  295. data["voided"] = voided
  296. data["archived"] = archived
  297. data["title"] = "入库单" if kind == "inbound" else "出库单"
  298. data["stops"] = []
  299. if plan and plan_has_load_stop(db, header.tenant_no, plan.id):
  300. segments = load_io_order_site_stop_segments(
  301. db,
  302. tenant_no=header.tenant_no,
  303. plan_id=plan.id,
  304. transport_id=header.transport_id,
  305. )
  306. data["stops"] = build_public_stop_reads_for_segments(
  307. db,
  308. tenant_no=header.tenant_no,
  309. plan_id=plan.id,
  310. segments=segments,
  311. )
  312. return data
  313. def _format_file_size(size_bytes: int) -> str:
  314. if size_bytes < 1024:
  315. return f"{size_bytes} B"
  316. if size_bytes < 1024 * 1024:
  317. return f"{size_bytes / 1024:.1f} KB"
  318. return f"{size_bytes / (1024 * 1024):.1f} MB"
  319. def _make_handlers(kind: str):
  320. def get_page(token: str, db: Session = Depends(get_db)):
  321. header, items = _get_by_token(db, kind, token)
  322. return success_response(_build_page(db, kind, header, items))
  323. def get_history(
  324. token: str,
  325. date: str = Query(..., description="筛选日 YYYY-MM-DD"),
  326. q: Optional[str] = Query(None, description="车牌号/产品名称模糊"),
  327. page: int = Query(1, ge=1, description="页码,从 1 开始"),
  328. page_size: int = Query(20, ge=1, le=500, alias="pageSize", description="每页条数"),
  329. db: Session = Depends(get_db),
  330. ):
  331. try:
  332. history_page = _build_history_page(
  333. db,
  334. entry_kind=kind,
  335. token=token,
  336. date_raw=date,
  337. keyword=q,
  338. page=page,
  339. page_size=page_size,
  340. )
  341. except HTTPException as exc:
  342. if exc.status_code == 400:
  343. return error_response(400, str(exc.detail))
  344. raise
  345. return success_response(history_page.model_dump(by_alias=True))
  346. def save_feedback(token: str, payload: PublicIoOrderFeedback, db: Session = Depends(get_db)):
  347. header, items = _get_by_token(db, kind, token)
  348. page = _build_page(db, kind, header, items)
  349. if page["voided"]:
  350. return error_response(400, "单据已作废,无法提交")
  351. if page["archived"]:
  352. return error_response(400, "运输计划已归档,无法提交")
  353. use_multi_stop = bool(payload.stops)
  354. qty_map: dict[str, Decimal] = {}
  355. brand_qty_map: dict[str, list[dict]] | None = None
  356. images = payload.images
  357. try:
  358. if use_multi_stop:
  359. qty_map, brand_qty_map, merged_images = apply_io_order_stop_site_feedbacks(
  360. db,
  361. kind=kind,
  362. header=header,
  363. items=items,
  364. stops=[stop.model_dump(by_alias=True) for stop in payload.stops],
  365. enforce_brand=(payload.status == "completed"),
  366. )
  367. images = merged_images
  368. if payload.items:
  369. for x in payload.items:
  370. qty_map[x.id] = Decimal(str(x.actual_qty))
  371. if any(x.brand_qtys is not None for x in payload.items):
  372. brand_qty_map = brand_qty_map or {}
  373. for x in payload.items:
  374. if x.brand_qtys is not None:
  375. brand_qty_map[x.id] = [
  376. b.model_dump(by_alias=True) for b in x.brand_qtys
  377. ]
  378. else:
  379. qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items}
  380. if any(x.brand_qtys is not None for x in payload.items):
  381. brand_qty_map = {}
  382. for x in payload.items:
  383. if x.brand_qtys is not None:
  384. brand_qty_map[x.id] = [
  385. b.model_dump(by_alias=True) for b in x.brand_qtys
  386. ]
  387. if not images:
  388. images = parse_feedback_images(header.site_feedback_images)
  389. apply_site_feedback(
  390. db,
  391. kind=kind,
  392. header=header,
  393. items=items,
  394. item_qty=qty_map,
  395. remark=payload.remark,
  396. images=images,
  397. status=payload.status,
  398. item_brand_qtys=brand_qty_map,
  399. enforce_brand=(payload.status == "completed")
  400. and (not use_multi_stop or bool(payload.items)),
  401. )
  402. except ValueError as exc:
  403. return error_response(400, str(exc))
  404. db.commit()
  405. return success_response({"success": True, "message": "保存成功"})
  406. def upload_attachment(
  407. token: str,
  408. file: UploadFile = File(...),
  409. folder: Optional[str] = Form(""),
  410. db: Session = Depends(get_db),
  411. ):
  412. header, _items = _get_by_token(db, kind, token)
  413. page = _build_page(db, kind, header, _items)
  414. if page["voided"]:
  415. return error_response(400, "单据已作废,无法上传附件")
  416. if page["archived"]:
  417. return error_response(400, "运输计划已归档,无法上传附件")
  418. if not file or not file.filename:
  419. return error_response(400, "请选择文件")
  420. try:
  421. effective_folder = resolve_upload_folder(
  422. folder=folder or "", biz_type="pl_io_order_site_feedback"
  423. )
  424. file_content = file.file.read()
  425. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  426. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  427. if base_url and full_url.startswith(base_url + "/"):
  428. file_url = full_url[len(base_url) + 1 :]
  429. else:
  430. file_url = full_url
  431. record = oss_svc.create_oss(
  432. db,
  433. tenant_no=header.tenant_no,
  434. url=file_url,
  435. original_name=file.filename,
  436. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  437. file_size=len(file_content),
  438. file_type=file.content_type or "",
  439. biz_type="pl_io_order_site_feedback",
  440. user_id=None,
  441. ext1=effective_folder or None,
  442. )
  443. db.commit()
  444. return success_response(
  445. {
  446. "id": record.id,
  447. "fileName": record.original_name,
  448. "fileUrl": record.url,
  449. "fileSize": record.file_size,
  450. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  451. "fileType": record.file_type,
  452. }
  453. )
  454. except Exception as exc:
  455. db.rollback()
  456. return error_response(500, f"上传失败: {str(exc)}")
  457. get_page.__name__ = f"get_public_{kind}_order"
  458. get_history.__name__ = f"get_public_{kind}_history"
  459. save_feedback.__name__ = f"save_public_{kind}_feedback"
  460. upload_attachment.__name__ = f"upload_public_{kind}_attachment"
  461. return get_page, get_history, save_feedback, upload_attachment
  462. _in_get, _in_hist, _in_fb, _in_up = _make_handlers("inbound")
  463. _out_get, _out_hist, _out_fb, _out_up = _make_handlers("outbound")
  464. inbound_public_router.get("/{token}/history")(_in_hist)
  465. inbound_public_router.get("/{token}")(_in_get)
  466. inbound_public_router.post("/{token}/feedback")(_in_fb)
  467. inbound_public_router.post("/{token}/attachments")(_in_up)
  468. outbound_public_router.get("/{token}/history")(_out_hist)
  469. outbound_public_router.get("/{token}")(_out_get)
  470. outbound_public_router.post("/{token}/feedback")(_out_fb)
  471. outbound_public_router.post("/{token}/attachments")(_out_up)