public_io_order.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. """运输计划 · 入库/出库单现场免登录公共 API"""
  2. from __future__ import annotations
  3. import os
  4. from decimal import Decimal
  5. from typing import Optional
  6. from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
  7. from sqlalchemy.orm import Session
  8. from app.core.attachment_biz import resolve_upload_folder
  9. from app.core.config import settings
  10. from app.core.database import get_db
  11. from app.core.response import error_response, success_response
  12. from app.core.storage import storage
  13. from app.crud import oss as oss_svc
  14. from app.models.pl_models import (
  15. PlInboundOrder,
  16. PlInboundOrderItem,
  17. PlOutboundOrder,
  18. PlOutboundOrderItem,
  19. PlPlan,
  20. )
  21. from app.schemas.pl.io_order import PublicIoOrderFeedback
  22. from app.services.pl_io_order_service import apply_site_feedback, order_header_to_dict, parse_feedback_images
  23. inbound_public_router = APIRouter(prefix="/public/pl/inbound", tags=["运输计划-入库单公共"])
  24. outbound_public_router = APIRouter(prefix="/public/pl/outbound", tags=["运输计划-出库单公共"])
  25. def _models(kind: str):
  26. if kind == "inbound":
  27. return PlInboundOrder, PlInboundOrderItem
  28. return PlOutboundOrder, PlOutboundOrderItem
  29. def _get_by_token(db: Session, kind: str, token: str):
  30. header_m, item_m = _models(kind)
  31. header = (
  32. db.query(header_m)
  33. .filter(header_m.site_access_token == token, header_m.del_flag == 0)
  34. .first()
  35. )
  36. if not header:
  37. raise HTTPException(status_code=404, detail="链接无效或已失效")
  38. items = (
  39. db.query(item_m)
  40. .filter(item_m.order_id == header.id, item_m.del_flag == 0)
  41. .order_by(item_m.order_num.asc())
  42. .all()
  43. )
  44. return header, items
  45. def _resolve_io_order_link_flags(plan: Optional[PlPlan], header) -> tuple[bool, bool]:
  46. """返回 (作废, 归档)。现场链接实际走入库/出库单公共页。"""
  47. voided = header.status == "voided" or bool(plan and plan.follow_status == "cancelled")
  48. archived = bool(plan and plan.archive_status == "archived")
  49. return voided, archived
  50. def _build_page(db: Session, kind: str, header, items) -> dict:
  51. plan = db.query(PlPlan).filter(PlPlan.id == header.plan_id, PlPlan.del_flag == 0).first()
  52. voided, archived = _resolve_io_order_link_flags(plan, header)
  53. data = order_header_to_dict(
  54. kind, header, items, db=db, public_unit_as_label=True
  55. )
  56. data["voided"] = voided
  57. data["archived"] = archived
  58. data["title"] = "入库单" if kind == "inbound" else "出库单"
  59. return data
  60. def _format_file_size(size_bytes: int) -> str:
  61. if size_bytes < 1024:
  62. return f"{size_bytes} B"
  63. if size_bytes < 1024 * 1024:
  64. return f"{size_bytes / 1024:.1f} KB"
  65. return f"{size_bytes / (1024 * 1024):.1f} MB"
  66. def _make_handlers(kind: str):
  67. def get_page(token: str, db: Session = Depends(get_db)):
  68. header, items = _get_by_token(db, kind, token)
  69. return success_response(_build_page(db, kind, header, items))
  70. def save_feedback(token: str, payload: PublicIoOrderFeedback, db: Session = Depends(get_db)):
  71. header, items = _get_by_token(db, kind, token)
  72. page = _build_page(db, kind, header, items)
  73. if page["voided"]:
  74. return error_response(400, "单据已作废,无法提交")
  75. if page["archived"]:
  76. return error_response(400, "运输计划已归档,无法提交")
  77. qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items}
  78. brand_qty_map: dict[str, list[dict]] | None = None
  79. if any(x.brand_qtys is not None for x in payload.items):
  80. brand_qty_map = {}
  81. for x in payload.items:
  82. if x.brand_qtys is not None:
  83. brand_qty_map[x.id] = [
  84. b.model_dump(by_alias=True) for b in x.brand_qtys
  85. ]
  86. images = payload.images
  87. if not images:
  88. images = parse_feedback_images(header.site_feedback_images)
  89. try:
  90. apply_site_feedback(
  91. db,
  92. kind=kind,
  93. header=header,
  94. items=items,
  95. item_qty=qty_map,
  96. remark=payload.remark,
  97. images=images,
  98. status=payload.status,
  99. item_brand_qtys=brand_qty_map,
  100. enforce_brand=(payload.status == "completed"),
  101. )
  102. except ValueError as exc:
  103. return error_response(400, str(exc))
  104. db.commit()
  105. return success_response({"success": True, "message": "保存成功"})
  106. def upload_attachment(
  107. token: str,
  108. file: UploadFile = File(...),
  109. folder: Optional[str] = Form(""),
  110. db: Session = Depends(get_db),
  111. ):
  112. header, _items = _get_by_token(db, kind, token)
  113. page = _build_page(db, kind, header, _items)
  114. if page["voided"]:
  115. return error_response(400, "单据已作废,无法上传附件")
  116. if page["archived"]:
  117. return error_response(400, "运输计划已归档,无法上传附件")
  118. if not file or not file.filename:
  119. return error_response(400, "请选择文件")
  120. try:
  121. effective_folder = resolve_upload_folder(
  122. folder=folder or "", biz_type="pl_io_order_site_feedback"
  123. )
  124. file_content = file.file.read()
  125. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  126. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  127. if base_url and full_url.startswith(base_url + "/"):
  128. file_url = full_url[len(base_url) + 1 :]
  129. else:
  130. file_url = full_url
  131. record = oss_svc.create_oss(
  132. db,
  133. tenant_no=header.tenant_no,
  134. url=file_url,
  135. original_name=file.filename,
  136. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  137. file_size=len(file_content),
  138. file_type=file.content_type or "",
  139. biz_type="pl_io_order_site_feedback",
  140. user_id=None,
  141. ext1=effective_folder or None,
  142. )
  143. db.commit()
  144. return success_response(
  145. {
  146. "id": record.id,
  147. "fileName": record.original_name,
  148. "fileUrl": record.url,
  149. "fileSize": record.file_size,
  150. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  151. "fileType": record.file_type,
  152. }
  153. )
  154. except Exception as exc:
  155. db.rollback()
  156. return error_response(500, f"上传失败: {str(exc)}")
  157. get_page.__name__ = f"get_public_{kind}_order"
  158. save_feedback.__name__ = f"save_public_{kind}_feedback"
  159. upload_attachment.__name__ = f"upload_public_{kind}_attachment"
  160. return get_page, save_feedback, upload_attachment
  161. _in_get, _in_fb, _in_up = _make_handlers("inbound")
  162. _out_get, _out_fb, _out_up = _make_handlers("outbound")
  163. inbound_public_router.get("/{token}")(_in_get)
  164. inbound_public_router.post("/{token}/feedback")(_in_fb)
  165. inbound_public_router.post("/{token}/attachments")(_in_up)
  166. outbound_public_router.get("/{token}")(_out_get)
  167. outbound_public_router.post("/{token}/feedback")(_out_fb)
  168. outbound_public_router.post("/{token}/attachments")(_out_up)