io_order.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. """运输计划 · 入库单 / 出库单管理端 API"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import List, Optional
  6. from pydantic import BaseModel, Field
  7. from fastapi import APIRouter, Depends, HTTPException, Query
  8. from sqlalchemy import or_
  9. from sqlalchemy.orm import Session
  10. from app.core.database import get_db
  11. from app.core.db_auth import DbUserContext, get_db_current_user
  12. from app.core.tenant_context import get_tenant_no
  13. from app.utils.excel_export import (
  14. EXPORT_MAX_ROWS,
  15. ExportColumn,
  16. ExportValidationError,
  17. export_rows_to_xlsx_response,
  18. filter_export_columns,
  19. )
  20. from app.models.pl_models import (
  21. PlInboundOrder,
  22. PlInboundOrderItem,
  23. PlOutboundOrder,
  24. PlOutboundOrderItem,
  25. )
  26. from app.schemas.pl.io_order import IoOrderListResponse, IoOrderPatch, IoOrderRead
  27. from app.services.pl_io_order_service import (
  28. dump_feedback_images,
  29. order_header_to_dict,
  30. update_io_order_items_qty_and_brand,
  31. )
  32. inbound_router = APIRouter(prefix="/inbound", tags=["运输计划-入库单"])
  33. outbound_router = APIRouter(prefix="/outbound", tags=["运输计划-出库单"])
  34. _STATUS_OK = {"in_progress", "completed", "voided"}
  35. def _models(kind: str):
  36. if kind == "inbound":
  37. return PlInboundOrder, PlInboundOrderItem
  38. return PlOutboundOrder, PlOutboundOrderItem
  39. def _get_order(db: Session, tenant_no: str, kind: str, order_id: str):
  40. header_m, item_m = _models(kind)
  41. header = (
  42. db.query(header_m)
  43. .filter(header_m.id == order_id, header_m.tenant_no == tenant_no, header_m.del_flag == 0)
  44. .first()
  45. )
  46. if not header:
  47. raise HTTPException(status_code=404, detail="单据不存在")
  48. items = (
  49. db.query(item_m)
  50. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  51. .order_by(item_m.order_num.asc(), item_m.create_time.asc())
  52. .all()
  53. )
  54. return header, items
  55. def _to_read(kind: str, header, items, db: Session) -> IoOrderRead:
  56. return IoOrderRead.model_validate(
  57. order_header_to_dict(kind, header, items, db=db)
  58. )
  59. def _list_orders(
  60. *,
  61. kind: str,
  62. order_no: Optional[str],
  63. plan_no: Optional[str],
  64. status: Optional[str],
  65. partner_company: Optional[str],
  66. vehicle_no: Optional[str],
  67. q: Optional[str],
  68. plan_date_from: Optional[str],
  69. plan_date_to: Optional[str],
  70. page: int,
  71. page_size: int,
  72. db: Session,
  73. user: DbUserContext,
  74. ids: Optional[list] = None,
  75. max_page_size: Optional[int] = None,
  76. ) -> IoOrderListResponse:
  77. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  78. header_m, item_m = _models(kind)
  79. query = db.query(header_m).filter(header_m.tenant_no == tenant_no, header_m.del_flag == 0)
  80. if order_no:
  81. query = query.filter(header_m.order_no.contains(order_no))
  82. if plan_no:
  83. query = query.filter(header_m.plan_no.contains(plan_no))
  84. if status:
  85. query = query.filter(header_m.status == status)
  86. if partner_company:
  87. query = query.filter(header_m.partner_company.contains(partner_company))
  88. if vehicle_no:
  89. query = query.filter(header_m.vehicle_no.contains(vehicle_no))
  90. if plan_date_from:
  91. query = query.filter(header_m.plan_date >= plan_date_from)
  92. if plan_date_to:
  93. query = query.filter(header_m.plan_date <= plan_date_to)
  94. if q:
  95. query = query.filter(
  96. or_(
  97. header_m.order_no.contains(q),
  98. header_m.plan_no.contains(q),
  99. header_m.partner_company.contains(q),
  100. header_m.vehicle_no.contains(q),
  101. )
  102. )
  103. if ids:
  104. query = query.filter(header_m.id.in_(ids))
  105. total = query.count()
  106. page = max(1, page)
  107. cap = int(max_page_size) if max_page_size else 500
  108. page_size = min(max(1, page_size), cap)
  109. rows = (
  110. query.order_by(header_m.create_time.desc())
  111. .offset((page - 1) * page_size)
  112. .limit(page_size)
  113. .all()
  114. )
  115. result: list[IoOrderRead] = []
  116. for row in rows:
  117. items = (
  118. db.query(item_m)
  119. .filter(item_m.order_id == row.id, item_m.del_flag == 0)
  120. .order_by(item_m.order_num.asc())
  121. .all()
  122. )
  123. result.append(_to_read(kind, row, items, db))
  124. return IoOrderListResponse(total=total, list=result)
  125. def _patch_order(
  126. *,
  127. kind: str,
  128. order_id: str,
  129. payload: IoOrderPatch,
  130. db: Session,
  131. user: DbUserContext,
  132. ) -> IoOrderRead:
  133. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  134. header, items = _get_order(db, tenant_no, kind, order_id)
  135. if payload.status is not None:
  136. if payload.status not in _STATUS_OK:
  137. raise HTTPException(status_code=400, detail="无效状态")
  138. header.status = payload.status
  139. if payload.site_feedback_remark is not None:
  140. header.site_feedback_remark = (payload.site_feedback_remark or "").strip() or None
  141. if payload.site_feedback_images is not None:
  142. header.site_feedback_images = dump_feedback_images(payload.site_feedback_images)
  143. header.site_feedback_time = datetime.now()
  144. if payload.remark is not None:
  145. header.remark = payload.remark
  146. if payload.items:
  147. by_id = {it.id: it for it in items}
  148. qty_map: dict[str, Decimal] = {}
  149. brand_qty_map: dict[str, list[dict]] | None = None
  150. for patch in payload.items:
  151. row = by_id.get(patch.id)
  152. if not row:
  153. continue
  154. qty_map[patch.id] = Decimal(str(patch.actual_qty))
  155. if patch.brand_qtys is not None:
  156. if brand_qty_map is None:
  157. brand_qty_map = {}
  158. brand_qty_map[patch.id] = [
  159. b.model_dump(by_alias=True) for b in patch.brand_qtys
  160. ]
  161. effective_status = payload.status if payload.status is not None else header.status
  162. try:
  163. update_io_order_items_qty_and_brand(
  164. db,
  165. kind=kind,
  166. tenant_no=tenant_no,
  167. order_id=header.id,
  168. items=items,
  169. item_qty=qty_map,
  170. item_brand_qtys=brand_qty_map,
  171. enforce_brand=(effective_status == "completed"),
  172. user_id=user.id,
  173. )
  174. except ValueError as exc:
  175. raise HTTPException(status_code=400, detail=str(exc)) from exc
  176. header.update_by = user.id
  177. db.commit()
  178. header, items = _get_order(db, tenant_no, kind, order_id)
  179. return _to_read(kind, header, items, db)
  180. def _get_by_plan(db: Session, tenant_no: str, kind: str, plan_id: str):
  181. """按计划查找入库/出库单;未落单时返回 (None, []),与运费 by-transport 一致。"""
  182. header_m, item_m = _models(kind)
  183. header = (
  184. db.query(header_m)
  185. .filter(
  186. header_m.tenant_no == tenant_no,
  187. header_m.plan_id == plan_id,
  188. header_m.del_flag == 0,
  189. )
  190. .order_by(header_m.create_time.desc())
  191. .first()
  192. )
  193. if not header:
  194. return None, []
  195. items = (
  196. db.query(item_m)
  197. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  198. .order_by(item_m.order_num.asc(), item_m.create_time.asc())
  199. .all()
  200. )
  201. return header, items
  202. @inbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead])
  203. def get_inbound_by_plan(
  204. plan_id: str,
  205. db: Session = Depends(get_db),
  206. user: DbUserContext = Depends(get_db_current_user),
  207. ) -> Optional[IoOrderRead]:
  208. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  209. header, items = _get_by_plan(db, tenant_no, "inbound", plan_id)
  210. if not header:
  211. return None
  212. return _to_read("inbound", header, items, db)
  213. @inbound_router.get("/", response_model=IoOrderListResponse)
  214. def list_inbound(
  215. order_no: Optional[str] = Query(default=None, alias="orderNo"),
  216. plan_no: Optional[str] = Query(default=None, alias="planNo"),
  217. status: Optional[str] = Query(default=None),
  218. partner_company: Optional[str] = Query(default=None, alias="partnerCompany"),
  219. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"),
  220. q: Optional[str] = Query(default=None),
  221. plan_date_from: Optional[str] = Query(default=None, alias="planDateFrom"),
  222. plan_date_to: Optional[str] = Query(default=None, alias="planDateTo"),
  223. page: int = Query(default=1, ge=1),
  224. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  225. db: Session = Depends(get_db),
  226. user: DbUserContext = Depends(get_db_current_user),
  227. ) -> IoOrderListResponse:
  228. return _list_orders(
  229. kind="inbound",
  230. order_no=order_no,
  231. plan_no=plan_no,
  232. status=status,
  233. partner_company=partner_company,
  234. vehicle_no=vehicle_no,
  235. q=q,
  236. plan_date_from=plan_date_from,
  237. plan_date_to=plan_date_to,
  238. page=page,
  239. page_size=page_size,
  240. db=db,
  241. user=user,
  242. )
  243. IO_ORDER_EXPORT_COLUMNS = [
  244. ExportColumn(title="单号", field="orderNo"),
  245. ExportColumn(title="状态", field="status"),
  246. ExportColumn(title="计划号", field="planNo"),
  247. ExportColumn(title="往来单位", field="partnerCompany"),
  248. ExportColumn(title="车牌", field="vehicleNo"),
  249. ExportColumn(title="取货地址", field="pickupAddress"),
  250. ExportColumn(title="送货地址", field="destinationAddress"),
  251. ExportColumn(title="计划日期", field="planDate"),
  252. ExportColumn(title="实际数量合计", field="actualQtyTotal"),
  253. ExportColumn(title="最近提交", field="siteFeedbackTime"),
  254. ]
  255. class IoOrderExportBody(BaseModel):
  256. order_no: Optional[str] = Field(default=None, alias="orderNo")
  257. plan_no: Optional[str] = Field(default=None, alias="planNo")
  258. status: Optional[str] = None
  259. partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
  260. vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo")
  261. q: Optional[str] = None
  262. plan_date_from: Optional[str] = Field(default=None, alias="planDateFrom")
  263. plan_date_to: Optional[str] = Field(default=None, alias="planDateTo")
  264. ids: Optional[List[str]] = None
  265. columns: Optional[List[str]] = None
  266. model_config = {"populate_by_name": True}
  267. def _export_orders(kind: str, body: IoOrderExportBody, db: Session, user: DbUserContext, sheet: str):
  268. try:
  269. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  270. resp = _list_orders(
  271. kind=kind,
  272. order_no=body.order_no,
  273. plan_no=body.plan_no,
  274. status=body.status,
  275. partner_company=body.partner_company,
  276. vehicle_no=body.vehicle_no,
  277. q=body.q,
  278. plan_date_from=body.plan_date_from,
  279. plan_date_to=body.plan_date_to,
  280. page=1,
  281. page_size=EXPORT_MAX_ROWS,
  282. db=db,
  283. user=user,
  284. ids=body.ids,
  285. max_page_size=EXPORT_MAX_ROWS,
  286. )
  287. rows = []
  288. for item in resp.list:
  289. data = item.model_dump(by_alias=True)
  290. for k, v in list(data.items()):
  291. if hasattr(v, 'isoformat'):
  292. data[k] = str(v)[:19]
  293. if not data.get('destinationAddress'):
  294. data['destinationAddress'] = data.get('destinationName') or ''
  295. rows.append(data)
  296. cols = filter_export_columns(IO_ORDER_EXPORT_COLUMNS, body.columns)
  297. date_str = datetime.now().strftime("%Y%m%d")
  298. return export_rows_to_xlsx_response(
  299. db, tenant_no=tenant_no, sheet_title=sheet, columns=cols, rows=rows,
  300. filename_stem=f"{sheet}_{date_str}",
  301. )
  302. except ExportValidationError as e:
  303. raise HTTPException(status_code=400, detail=str(e)) from e
  304. @inbound_router.post("/export")
  305. def export_inbound(
  306. body: IoOrderExportBody,
  307. db: Session = Depends(get_db),
  308. user: DbUserContext = Depends(get_db_current_user),
  309. ):
  310. return _export_orders("inbound", body, db, user, "入库单")
  311. @outbound_router.post("/export")
  312. def export_outbound(
  313. body: IoOrderExportBody,
  314. db: Session = Depends(get_db),
  315. user: DbUserContext = Depends(get_db_current_user),
  316. ):
  317. return _export_orders("outbound", body, db, user, "出库单")
  318. @inbound_router.get("/{id}", response_model=IoOrderRead)
  319. def get_inbound(
  320. id: str,
  321. db: Session = Depends(get_db),
  322. user: DbUserContext = Depends(get_db_current_user),
  323. ) -> IoOrderRead:
  324. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  325. header, items = _get_order(db, tenant_no, "inbound", id)
  326. return _to_read("inbound", header, items, db)
  327. @inbound_router.patch("/{id}", response_model=IoOrderRead)
  328. def patch_inbound(
  329. id: str,
  330. payload: IoOrderPatch,
  331. db: Session = Depends(get_db),
  332. user: DbUserContext = Depends(get_db_current_user),
  333. ) -> IoOrderRead:
  334. return _patch_order(kind="inbound", order_id=id, payload=payload, db=db, user=user)
  335. @inbound_router.get("/{id}/site-url")
  336. def inbound_site_url(
  337. id: str,
  338. db: Session = Depends(get_db),
  339. user: DbUserContext = Depends(get_db_current_user),
  340. ) -> dict:
  341. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  342. header, _ = _get_order(db, tenant_no, "inbound", id)
  343. data = order_header_to_dict("inbound", header, None)
  344. return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")}
  345. @outbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead])
  346. def get_outbound_by_plan(
  347. plan_id: str,
  348. db: Session = Depends(get_db),
  349. user: DbUserContext = Depends(get_db_current_user),
  350. ) -> Optional[IoOrderRead]:
  351. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  352. header, items = _get_by_plan(db, tenant_no, "outbound", plan_id)
  353. if not header:
  354. return None
  355. return _to_read("outbound", header, items, db)
  356. @outbound_router.get("/", response_model=IoOrderListResponse)
  357. def list_outbound(
  358. order_no: Optional[str] = Query(default=None, alias="orderNo"),
  359. plan_no: Optional[str] = Query(default=None, alias="planNo"),
  360. status: Optional[str] = Query(default=None),
  361. partner_company: Optional[str] = Query(default=None, alias="partnerCompany"),
  362. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"),
  363. q: Optional[str] = Query(default=None),
  364. plan_date_from: Optional[str] = Query(default=None, alias="planDateFrom"),
  365. plan_date_to: Optional[str] = Query(default=None, alias="planDateTo"),
  366. page: int = Query(default=1, ge=1),
  367. page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
  368. db: Session = Depends(get_db),
  369. user: DbUserContext = Depends(get_db_current_user),
  370. ) -> IoOrderListResponse:
  371. return _list_orders(
  372. kind="outbound",
  373. order_no=order_no,
  374. plan_no=plan_no,
  375. status=status,
  376. partner_company=partner_company,
  377. vehicle_no=vehicle_no,
  378. q=q,
  379. plan_date_from=plan_date_from,
  380. plan_date_to=plan_date_to,
  381. page=page,
  382. page_size=page_size,
  383. db=db,
  384. user=user,
  385. )
  386. @outbound_router.get("/{id}", response_model=IoOrderRead)
  387. def get_outbound(
  388. id: str,
  389. db: Session = Depends(get_db),
  390. user: DbUserContext = Depends(get_db_current_user),
  391. ) -> IoOrderRead:
  392. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  393. header, items = _get_order(db, tenant_no, "outbound", id)
  394. return _to_read("outbound", header, items, db)
  395. @outbound_router.patch("/{id}", response_model=IoOrderRead)
  396. def patch_outbound(
  397. id: str,
  398. payload: IoOrderPatch,
  399. db: Session = Depends(get_db),
  400. user: DbUserContext = Depends(get_db_current_user),
  401. ) -> IoOrderRead:
  402. return _patch_order(kind="outbound", order_id=id, payload=payload, db=db, user=user)
  403. @outbound_router.get("/{id}/site-url")
  404. def outbound_site_url(
  405. id: str,
  406. db: Session = Depends(get_db),
  407. user: DbUserContext = Depends(get_db_current_user),
  408. ) -> dict:
  409. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  410. header, _ = _get_order(db, tenant_no, "outbound", id)
  411. data = order_header_to_dict("outbound", header, None)
  412. return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")}