io_order.py 18 KB

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