io_order.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. """运输计划 · 入库单 / 出库单管理端 API"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from typing import Optional
  6. from fastapi import APIRouter, Depends, HTTPException, Query
  7. from sqlalchemy import or_
  8. from sqlalchemy.orm import Session
  9. from app.core.database import get_db
  10. from app.core.db_auth import DbUserContext, get_db_current_user
  11. from app.core.tenant_context import get_tenant_no
  12. from app.models.pl_models import (
  13. PlInboundOrder,
  14. PlInboundOrderItem,
  15. PlOutboundOrder,
  16. PlOutboundOrderItem,
  17. )
  18. from app.schemas.pl.io_order import IoOrderListResponse, IoOrderPatch, IoOrderRead
  19. from app.services.pl_io_order_service import (
  20. dump_feedback_images,
  21. order_header_to_dict,
  22. update_io_order_items_qty_and_brand,
  23. )
  24. inbound_router = APIRouter(prefix="/inbound", tags=["运输计划-入库单"])
  25. outbound_router = APIRouter(prefix="/outbound", tags=["运输计划-出库单"])
  26. _STATUS_OK = {"in_progress", "completed", "voided"}
  27. def _models(kind: str):
  28. if kind == "inbound":
  29. return PlInboundOrder, PlInboundOrderItem
  30. return PlOutboundOrder, PlOutboundOrderItem
  31. def _get_order(db: Session, tenant_no: str, kind: str, order_id: str):
  32. header_m, item_m = _models(kind)
  33. header = (
  34. db.query(header_m)
  35. .filter(header_m.id == order_id, header_m.tenant_no == tenant_no, header_m.del_flag == 0)
  36. .first()
  37. )
  38. if not header:
  39. raise HTTPException(status_code=404, detail="单据不存在")
  40. items = (
  41. db.query(item_m)
  42. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  43. .order_by(item_m.order_num.asc(), item_m.create_time.asc())
  44. .all()
  45. )
  46. return header, items
  47. def _to_read(kind: str, header, items, db: Session) -> IoOrderRead:
  48. return IoOrderRead.model_validate(
  49. order_header_to_dict(kind, header, items, db=db)
  50. )
  51. def _list_orders(
  52. *,
  53. kind: str,
  54. order_no: Optional[str],
  55. plan_no: Optional[str],
  56. status: Optional[str],
  57. partner_company: Optional[str],
  58. vehicle_no: Optional[str],
  59. q: Optional[str],
  60. page: int,
  61. page_size: int,
  62. db: Session,
  63. user: DbUserContext,
  64. ) -> IoOrderListResponse:
  65. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  66. header_m, item_m = _models(kind)
  67. query = db.query(header_m).filter(header_m.tenant_no == tenant_no, header_m.del_flag == 0)
  68. if order_no:
  69. query = query.filter(header_m.order_no.contains(order_no))
  70. if plan_no:
  71. query = query.filter(header_m.plan_no.contains(plan_no))
  72. if status:
  73. query = query.filter(header_m.status == status)
  74. if partner_company:
  75. query = query.filter(header_m.partner_company.contains(partner_company))
  76. if vehicle_no:
  77. query = query.filter(header_m.vehicle_no.contains(vehicle_no))
  78. if q:
  79. query = query.filter(
  80. or_(
  81. header_m.order_no.contains(q),
  82. header_m.plan_no.contains(q),
  83. header_m.partner_company.contains(q),
  84. header_m.vehicle_no.contains(q),
  85. )
  86. )
  87. total = query.count()
  88. rows = (
  89. query.order_by(header_m.create_time.desc())
  90. .offset((page - 1) * page_size)
  91. .limit(page_size)
  92. .all()
  93. )
  94. result: list[IoOrderRead] = []
  95. for row in rows:
  96. items = (
  97. db.query(item_m)
  98. .filter(item_m.order_id == row.id, item_m.del_flag == 0)
  99. .order_by(item_m.order_num.asc())
  100. .all()
  101. )
  102. result.append(_to_read(kind, row, items, db))
  103. return IoOrderListResponse(total=total, list=result)
  104. def _patch_order(
  105. *,
  106. kind: str,
  107. order_id: str,
  108. payload: IoOrderPatch,
  109. db: Session,
  110. user: DbUserContext,
  111. ) -> IoOrderRead:
  112. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  113. header, items = _get_order(db, tenant_no, kind, order_id)
  114. if payload.status is not None:
  115. if payload.status not in _STATUS_OK:
  116. raise HTTPException(status_code=400, detail="无效状态")
  117. header.status = payload.status
  118. if payload.site_feedback_remark is not None:
  119. header.site_feedback_remark = (payload.site_feedback_remark or "").strip() or None
  120. if payload.site_feedback_images is not None:
  121. header.site_feedback_images = dump_feedback_images(payload.site_feedback_images)
  122. header.site_feedback_time = datetime.now()
  123. if payload.remark is not None:
  124. header.remark = payload.remark
  125. if payload.items:
  126. by_id = {it.id: it for it in items}
  127. qty_map: dict[str, Decimal] = {}
  128. brand_qty_map: dict[str, list[dict]] | None = None
  129. for patch in payload.items:
  130. row = by_id.get(patch.id)
  131. if not row:
  132. continue
  133. qty_map[patch.id] = Decimal(str(patch.actual_qty))
  134. if patch.brand_qtys is not None:
  135. if brand_qty_map is None:
  136. brand_qty_map = {}
  137. brand_qty_map[patch.id] = [
  138. b.model_dump(by_alias=True) for b in patch.brand_qtys
  139. ]
  140. effective_status = payload.status if payload.status is not None else header.status
  141. try:
  142. update_io_order_items_qty_and_brand(
  143. db,
  144. kind=kind,
  145. tenant_no=tenant_no,
  146. order_id=header.id,
  147. items=items,
  148. item_qty=qty_map,
  149. item_brand_qtys=brand_qty_map,
  150. enforce_brand=(effective_status == "completed"),
  151. user_id=user.id,
  152. )
  153. except ValueError as exc:
  154. raise HTTPException(status_code=400, detail=str(exc)) from exc
  155. header.update_by = user.id
  156. db.commit()
  157. header, items = _get_order(db, tenant_no, kind, order_id)
  158. return _to_read(kind, header, items, db)
  159. def _get_by_plan(db: Session, tenant_no: str, kind: str, plan_id: str):
  160. """按计划查找入库/出库单;未落单时返回 (None, []),与运费 by-transport 一致。"""
  161. header_m, item_m = _models(kind)
  162. header = (
  163. db.query(header_m)
  164. .filter(
  165. header_m.tenant_no == tenant_no,
  166. header_m.plan_id == plan_id,
  167. header_m.del_flag == 0,
  168. )
  169. .order_by(header_m.create_time.desc())
  170. .first()
  171. )
  172. if not header:
  173. return None, []
  174. items = (
  175. db.query(item_m)
  176. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  177. .order_by(item_m.order_num.asc(), item_m.create_time.asc())
  178. .all()
  179. )
  180. return header, items
  181. @inbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead])
  182. def get_inbound_by_plan(
  183. plan_id: str,
  184. db: Session = Depends(get_db),
  185. user: DbUserContext = Depends(get_db_current_user),
  186. ) -> Optional[IoOrderRead]:
  187. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  188. header, items = _get_by_plan(db, tenant_no, "inbound", plan_id)
  189. if not header:
  190. return None
  191. return _to_read("inbound", header, items, db)
  192. @inbound_router.get("/", response_model=IoOrderListResponse)
  193. def list_inbound(
  194. order_no: Optional[str] = Query(default=None, alias="orderNo"),
  195. plan_no: Optional[str] = Query(default=None, alias="planNo"),
  196. status: Optional[str] = Query(default=None),
  197. partner_company: Optional[str] = Query(default=None, alias="partnerCompany"),
  198. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"),
  199. q: Optional[str] = Query(default=None),
  200. page: int = Query(default=1, ge=1),
  201. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  202. db: Session = Depends(get_db),
  203. user: DbUserContext = Depends(get_db_current_user),
  204. ) -> IoOrderListResponse:
  205. return _list_orders(
  206. kind="inbound",
  207. order_no=order_no,
  208. plan_no=plan_no,
  209. status=status,
  210. partner_company=partner_company,
  211. vehicle_no=vehicle_no,
  212. q=q,
  213. page=page,
  214. page_size=page_size,
  215. db=db,
  216. user=user,
  217. )
  218. @inbound_router.get("/{id}", response_model=IoOrderRead)
  219. def get_inbound(
  220. id: str,
  221. db: Session = Depends(get_db),
  222. user: DbUserContext = Depends(get_db_current_user),
  223. ) -> IoOrderRead:
  224. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  225. header, items = _get_order(db, tenant_no, "inbound", id)
  226. return _to_read("inbound", header, items, db)
  227. @inbound_router.patch("/{id}", response_model=IoOrderRead)
  228. def patch_inbound(
  229. id: str,
  230. payload: IoOrderPatch,
  231. db: Session = Depends(get_db),
  232. user: DbUserContext = Depends(get_db_current_user),
  233. ) -> IoOrderRead:
  234. return _patch_order(kind="inbound", order_id=id, payload=payload, db=db, user=user)
  235. @inbound_router.get("/{id}/site-url")
  236. def inbound_site_url(
  237. id: str,
  238. db: Session = Depends(get_db),
  239. user: DbUserContext = Depends(get_db_current_user),
  240. ) -> dict:
  241. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  242. header, _ = _get_order(db, tenant_no, "inbound", id)
  243. data = order_header_to_dict("inbound", header, None)
  244. return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")}
  245. @outbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead])
  246. def get_outbound_by_plan(
  247. plan_id: str,
  248. db: Session = Depends(get_db),
  249. user: DbUserContext = Depends(get_db_current_user),
  250. ) -> Optional[IoOrderRead]:
  251. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  252. header, items = _get_by_plan(db, tenant_no, "outbound", plan_id)
  253. if not header:
  254. return None
  255. return _to_read("outbound", header, items, db)
  256. @outbound_router.get("/", response_model=IoOrderListResponse)
  257. def list_outbound(
  258. order_no: Optional[str] = Query(default=None, alias="orderNo"),
  259. plan_no: Optional[str] = Query(default=None, alias="planNo"),
  260. status: Optional[str] = Query(default=None),
  261. partner_company: Optional[str] = Query(default=None, alias="partnerCompany"),
  262. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"),
  263. q: Optional[str] = Query(default=None),
  264. page: int = Query(default=1, ge=1),
  265. page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
  266. db: Session = Depends(get_db),
  267. user: DbUserContext = Depends(get_db_current_user),
  268. ) -> IoOrderListResponse:
  269. return _list_orders(
  270. kind="outbound",
  271. order_no=order_no,
  272. plan_no=plan_no,
  273. status=status,
  274. partner_company=partner_company,
  275. vehicle_no=vehicle_no,
  276. q=q,
  277. page=page,
  278. page_size=page_size,
  279. db=db,
  280. user=user,
  281. )
  282. @outbound_router.get("/{id}", response_model=IoOrderRead)
  283. def get_outbound(
  284. id: str,
  285. db: Session = Depends(get_db),
  286. user: DbUserContext = Depends(get_db_current_user),
  287. ) -> IoOrderRead:
  288. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  289. header, items = _get_order(db, tenant_no, "outbound", id)
  290. return _to_read("outbound", header, items, db)
  291. @outbound_router.patch("/{id}", response_model=IoOrderRead)
  292. def patch_outbound(
  293. id: str,
  294. payload: IoOrderPatch,
  295. db: Session = Depends(get_db),
  296. user: DbUserContext = Depends(get_db_current_user),
  297. ) -> IoOrderRead:
  298. return _patch_order(kind="outbound", order_id=id, payload=payload, db=db, user=user)
  299. @outbound_router.get("/{id}/site-url")
  300. def outbound_site_url(
  301. id: str,
  302. db: Session = Depends(get_db),
  303. user: DbUserContext = Depends(get_db_current_user),
  304. ) -> dict:
  305. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  306. header, _ = _get_order(db, tenant_no, "outbound", id)
  307. data = order_header_to_dict("outbound", header, None)
  308. return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")}