public_inventory.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """地磅出入库单 · 工厂现场人员免登录公共 API"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from datetime import date, datetime
  6. from typing import Optional
  7. from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
  8. from sqlalchemy.orm import Session
  9. from app.core.attachment_biz import resolve_upload_folder
  10. from app.core.config import settings
  11. from app.core.database import get_db
  12. from app.core.response import error_response, success_response
  13. from app.core.storage import storage
  14. from app.crud import oss as oss_svc
  15. from app.models.db_models import DbInboundOutbound
  16. from app.models.pl_models import PlPlan, PlTransportDbLink
  17. from app.schemas.db.public_inventory import PublicInventoryFeedback, PublicInventoryPageRead
  18. from app.services.db.mappers import inventory_date_to_api
  19. from app.services.db.pound_qr_public import build_public_pound_qr_read
  20. router = APIRouter(prefix="/public/db/inventory", tags=["地磅-出入库现场公共"])
  21. COMPLETED_STATUS = "已完成"
  22. def _parse_feedback_images(raw: Optional[str]) -> list[str]:
  23. if not raw:
  24. return []
  25. try:
  26. parsed = json.loads(raw)
  27. if isinstance(parsed, list):
  28. return [str(item) for item in parsed if item]
  29. except (TypeError, json.JSONDecodeError):
  30. pass
  31. return [part.strip() for part in raw.split(",") if part.strip()]
  32. def _get_inventory_by_token(db: Session, token: str) -> DbInboundOutbound:
  33. row = (
  34. db.query(DbInboundOutbound)
  35. .filter(
  36. DbInboundOutbound.site_access_token == token,
  37. DbInboundOutbound.del_flag == 0,
  38. )
  39. .first()
  40. )
  41. if not row:
  42. raise HTTPException(status_code=404, detail="链接无效或已失效")
  43. return row
  44. def _resolve_plan_voided(db: Session, inventory: DbInboundOutbound) -> bool:
  45. link = (
  46. db.query(PlTransportDbLink)
  47. .filter(
  48. PlTransportDbLink.db_inventory_id == inventory.id,
  49. PlTransportDbLink.del_flag == 0,
  50. )
  51. .order_by(PlTransportDbLink.create_time.desc())
  52. .first()
  53. )
  54. if not link:
  55. return False
  56. plan = db.query(PlPlan).filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0).first()
  57. return bool(plan and plan.follow_status == "cancelled")
  58. def _assert_editable(inventory: DbInboundOutbound, voided: bool) -> None:
  59. if voided:
  60. raise HTTPException(status_code=400, detail="运输计划已作废,无法提交回执")
  61. if (inventory.status or "").strip() == COMPLETED_STATUS:
  62. raise HTTPException(status_code=400, detail="单据已完成,无法修改回执")
  63. def _build_public_page(db: Session, inventory: DbInboundOutbound) -> PublicInventoryPageRead:
  64. voided = _resolve_plan_voided(db, inventory)
  65. read_only = (inventory.status or "").strip() == COMPLETED_STATUS
  66. return PublicInventoryPageRead(
  67. batch_no=inventory.batch_no,
  68. business_type=inventory.business_type,
  69. date=inventory_date_to_api(inventory.date) or None,
  70. partner_company=inventory.partner_company,
  71. product=inventory.product,
  72. specification=inventory.specification,
  73. quantity=float(inventory.quantity or 0),
  74. vehicle_no=inventory.vehicle_no or "",
  75. plan_no=inventory.plan_no,
  76. status=inventory.status,
  77. remark=inventory.remark,
  78. site_feedback_remark=inventory.site_feedback_remark,
  79. site_feedback_images=_parse_feedback_images(inventory.site_feedback_images),
  80. site_feedback_time=inventory.site_feedback_time,
  81. read_only=read_only,
  82. voided=voided,
  83. )
  84. @router.get("/{inventory_id}/pound-qr")
  85. def get_public_pound_qr(inventory_id: str, db: Session = Depends(get_db)):
  86. row = (
  87. db.query(DbInboundOutbound)
  88. .filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
  89. .first()
  90. )
  91. if not row:
  92. raise HTTPException(status_code=404, detail="链接无效或单据不存在")
  93. read = build_public_pound_qr_read(row, quantity_unit="吨")
  94. return success_response(read.model_dump(by_alias=True))
  95. @router.get("/{token}")
  96. def get_public_inventory(token: str, db: Session = Depends(get_db)):
  97. inventory = _get_inventory_by_token(db, token)
  98. page = _build_public_page(db, inventory)
  99. return success_response(page.model_dump(by_alias=True))
  100. @router.post("/{token}/feedback")
  101. def save_public_feedback(
  102. token: str,
  103. payload: PublicInventoryFeedback,
  104. db: Session = Depends(get_db),
  105. ):
  106. inventory = _get_inventory_by_token(db, token)
  107. page = _build_public_page(db, inventory)
  108. if page.voided:
  109. return error_response(400, "运输计划已作废,无法提交回执")
  110. if page.read_only:
  111. return error_response(400, "单据已完成,无法修改回执")
  112. if payload.site_feedback_images is not None:
  113. inventory.site_feedback_images = json.dumps(payload.site_feedback_images)
  114. if payload.site_feedback_remark is not None:
  115. inventory.site_feedback_remark = payload.site_feedback_remark
  116. inventory.site_feedback_time = datetime.now()
  117. db.commit()
  118. return success_response({"success": True, "message": "保存成功"})
  119. def _format_file_size(size_bytes: int) -> str:
  120. if size_bytes < 1024:
  121. return f"{size_bytes} B"
  122. if size_bytes < 1024 * 1024:
  123. return f"{size_bytes / 1024:.1f} KB"
  124. return f"{size_bytes / (1024 * 1024):.1f} MB"
  125. @router.post("/{token}/attachments")
  126. def upload_public_attachment(
  127. token: str,
  128. file: UploadFile = File(...),
  129. folder: Optional[str] = Form(""),
  130. db: Session = Depends(get_db),
  131. ):
  132. inventory = _get_inventory_by_token(db, token)
  133. page = _build_public_page(db, inventory)
  134. if page.voided:
  135. return error_response(400, "运输计划已作废,无法上传附件")
  136. if page.read_only:
  137. return error_response(400, "单据已完成,无法修改回执")
  138. if not file or not file.filename:
  139. return error_response(400, "请选择文件")
  140. try:
  141. effective_folder = resolve_upload_folder(
  142. folder=folder or "", biz_type="db_inventory_site_feedback"
  143. )
  144. file_content = file.file.read()
  145. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  146. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  147. if base_url and full_url.startswith(base_url + "/"):
  148. file_url = full_url[len(base_url) + 1 :]
  149. else:
  150. file_url = full_url
  151. record = oss_svc.create_oss(
  152. db,
  153. tenant_no=inventory.tenant_no,
  154. url=file_url,
  155. original_name=file.filename,
  156. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  157. file_size=len(file_content),
  158. file_type=file.content_type or "",
  159. biz_type="db_inventory_site_feedback",
  160. user_id=None,
  161. ext1=effective_folder or None,
  162. )
  163. db.commit()
  164. return success_response(
  165. {
  166. "id": record.id,
  167. "fileName": record.original_name,
  168. "fileUrl": record.url,
  169. "fileSize": record.file_size,
  170. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  171. "fileType": record.file_type,
  172. }
  173. )
  174. except Exception as exc:
  175. db.rollback()
  176. return error_response(500, f"上传失败: {str(exc)}")