public_site.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. """运输计划 · 工厂现场人员免登录公共 API(pl_transport_site_link)"""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from datetime import 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.pl_models import PlPlan, PlPlanProduct, PlTransportSiteLink
  16. from app.schemas.db.public_inventory import PublicInventoryFeedback
  17. from app.schemas.pl.transport import PublicTransportPlanProductRead, PublicTransportSitePageRead
  18. from app.services.db.mappers import inventory_date_to_api
  19. from app.services.pl_product_display import (
  20. load_crm_product_alias_maps,
  21. resolve_product_display_names,
  22. )
  23. from app.utils.excel_export import load_dict_label_maps
  24. router = APIRouter(prefix="/public/pl/site", tags=["运输计划-现场公共"])
  25. CRM_PRODUCT_UNIT = "crm_product_unit"
  26. def _parse_feedback_images(raw: Optional[str]) -> list[str]:
  27. if not raw:
  28. return []
  29. try:
  30. parsed = json.loads(raw)
  31. if isinstance(parsed, list):
  32. return [str(item) for item in parsed if item]
  33. except (TypeError, json.JSONDecodeError):
  34. pass
  35. return [part.strip() for part in raw.split(",") if part.strip()]
  36. def _get_site_link_by_token(db: Session, token: str) -> PlTransportSiteLink:
  37. row = (
  38. db.query(PlTransportSiteLink)
  39. .filter(
  40. PlTransportSiteLink.site_access_token == token,
  41. PlTransportSiteLink.del_flag == 0,
  42. )
  43. .first()
  44. )
  45. if not row:
  46. raise HTTPException(status_code=404, detail="链接无效或已失效")
  47. return row
  48. def _resolve_site_link_flags(plan: Optional[PlPlan]) -> tuple[bool, bool]:
  49. """返回 (作废, 归档)。"""
  50. voided = bool(plan and plan.follow_status == "cancelled")
  51. archived = bool(plan and plan.archive_status == "archived")
  52. return voided, archived
  53. def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str:
  54. code = (unit_code or "").strip()
  55. if not code:
  56. return "吨"
  57. return unit_map.get(code, code)
  58. def _resolve_plan_product(db: Session, link: PlTransportSiteLink) -> Optional[PlPlanProduct]:
  59. if not link.plan_product_id:
  60. return None
  61. return (
  62. db.query(PlPlanProduct)
  63. .filter(
  64. PlPlanProduct.id == link.plan_product_id,
  65. PlPlanProduct.del_flag == 0,
  66. )
  67. .first()
  68. )
  69. def _build_site_product_read(
  70. db: Session,
  71. link: PlTransportSiteLink,
  72. plan_product: Optional[PlPlanProduct],
  73. ) -> PublicTransportPlanProductRead:
  74. unit_map = load_dict_label_maps(
  75. db, [CRM_PRODUCT_UNIT], tenant_no=link.tenant_no
  76. ).get(CRM_PRODUCT_UNIT, {})
  77. quantity = link.quantity
  78. unit = "吨"
  79. remark = None
  80. product_id = ""
  81. saved_name = (link.product or "").strip()
  82. brand_mode = None
  83. brand_id = None
  84. brand_name = None
  85. plan_product_id = ""
  86. if plan_product:
  87. plan_product_id = plan_product.id or ""
  88. product_id = (plan_product.product_id or "").strip()
  89. saved_name = (plan_product.product_name or saved_name or "").strip()
  90. quantity = (
  91. plan_product.quantity
  92. or plan_product.inbound_estimate_qty
  93. or plan_product.outbound_estimate_qty
  94. or link.quantity
  95. )
  96. unit = _resolve_unit_label(unit_map, plan_product.unit)
  97. remark = (plan_product.remark or "").strip() or None
  98. brand_mode = plan_product.brand_mode
  99. brand_id = plan_product.brand_id
  100. brand_name = plan_product.brand_name
  101. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  102. db,
  103. tenant_no=link.tenant_no,
  104. product_ids={product_id} if product_id else set(),
  105. )
  106. display_name, alias_name = resolve_product_display_names(
  107. saved_product_name=saved_name or link.product,
  108. product_id=product_id,
  109. standard_by_id=standard_by_id,
  110. alias_options_by_id=alias_options_by_id,
  111. )
  112. return PublicTransportPlanProductRead(
  113. plan_product_id=plan_product_id,
  114. product_name=display_name or (link.product or ""),
  115. alias_name=alias_name,
  116. model=link.specification or (plan_product.model if plan_product else None),
  117. package_spec=plan_product.package_spec if plan_product else None,
  118. unit=unit,
  119. quantity=quantity,
  120. remark=remark,
  121. brand_mode=brand_mode,
  122. brand_id=brand_id,
  123. brand_name=brand_name,
  124. )
  125. def _build_public_page(db: Session, link: PlTransportSiteLink) -> PublicTransportSitePageRead:
  126. plan = (
  127. db.query(PlPlan)
  128. .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
  129. .first()
  130. )
  131. voided, archived = _resolve_site_link_flags(plan)
  132. plan_product = _resolve_plan_product(db, link)
  133. product_read = _build_site_product_read(db, link, plan_product)
  134. return PublicTransportSitePageRead(
  135. business_type=link.business_type,
  136. date=inventory_date_to_api(link.plan_date) or None,
  137. partner_company=link.partner_company or "",
  138. vehicle_no=link.vehicle_no or "",
  139. plan_no=link.plan_no,
  140. remark=link.remark,
  141. pickup_address=plan.pickup_address if plan else None,
  142. pickup_lng=plan.pickup_lng if plan else None,
  143. pickup_lat=plan.pickup_lat if plan else None,
  144. pickup_time=plan.pickup_time if plan else None,
  145. pickup_contact=plan.pickup_contact if plan else None,
  146. pickup_notes=plan.pickup_notes if plan else None,
  147. destination_name=plan.destination_name if plan else None,
  148. destination_address=(
  149. (plan.destination_address or plan.destination_name) if plan else None
  150. ),
  151. destination_lng=plan.destination_lng if plan else None,
  152. destination_lat=plan.destination_lat if plan else None,
  153. delivery_time=plan.delivery_time if plan else None,
  154. delivery_contact=plan.delivery_contact if plan else None,
  155. destination_notes=plan.destination_notes if plan else None,
  156. products=[product_read],
  157. site_feedback_remark=link.site_feedback_remark,
  158. site_feedback_images=_parse_feedback_images(link.site_feedback_images),
  159. site_feedback_time=link.site_feedback_time,
  160. voided=voided,
  161. archived=archived,
  162. )
  163. @router.get("/{token}")
  164. def get_public_site(token: str, db: Session = Depends(get_db)):
  165. link = _get_site_link_by_token(db, token)
  166. page = _build_public_page(db, link)
  167. return success_response(page.model_dump(by_alias=True))
  168. @router.post("/{token}/feedback")
  169. def save_public_site_feedback(
  170. token: str,
  171. payload: PublicInventoryFeedback,
  172. db: Session = Depends(get_db),
  173. ):
  174. link = _get_site_link_by_token(db, token)
  175. page = _build_public_page(db, link)
  176. if page.voided:
  177. return error_response(400, "运输计划已作废,无法提交回执")
  178. if page.archived:
  179. return error_response(400, "运输计划已归档,无法提交回执")
  180. if payload.site_feedback_images is not None:
  181. link.site_feedback_images = json.dumps(payload.site_feedback_images)
  182. if payload.site_feedback_remark is not None:
  183. link.site_feedback_remark = payload.site_feedback_remark
  184. link.site_feedback_time = datetime.now()
  185. db.commit()
  186. return success_response({"success": True, "message": "保存成功"})
  187. def _format_file_size(size_bytes: int) -> str:
  188. if size_bytes < 1024:
  189. return f"{size_bytes} B"
  190. if size_bytes < 1024 * 1024:
  191. return f"{size_bytes / 1024:.1f} KB"
  192. return f"{size_bytes / (1024 * 1024):.1f} MB"
  193. @router.post("/{token}/attachments")
  194. def upload_public_site_attachment(
  195. token: str,
  196. file: UploadFile = File(...),
  197. folder: Optional[str] = Form(""),
  198. db: Session = Depends(get_db),
  199. ):
  200. link = _get_site_link_by_token(db, token)
  201. page = _build_public_page(db, link)
  202. if page.voided:
  203. return error_response(400, "运输计划已作废,无法上传附件")
  204. if page.archived:
  205. return error_response(400, "运输计划已归档,无法上传附件")
  206. if not file or not file.filename:
  207. return error_response(400, "请选择文件")
  208. try:
  209. effective_folder = resolve_upload_folder(
  210. folder=folder or "", biz_type="pl_transport_site_feedback"
  211. )
  212. file_content = file.file.read()
  213. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  214. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  215. if base_url and full_url.startswith(base_url + "/"):
  216. file_url = full_url[len(base_url) + 1 :]
  217. else:
  218. file_url = full_url
  219. record = oss_svc.create_oss(
  220. db,
  221. tenant_no=link.tenant_no,
  222. url=file_url,
  223. original_name=file.filename,
  224. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  225. file_size=len(file_content),
  226. file_type=file.content_type or "",
  227. biz_type="pl_transport_site_feedback",
  228. user_id=None,
  229. ext1=effective_folder or None,
  230. )
  231. db.commit()
  232. return success_response(
  233. {
  234. "id": record.id,
  235. "fileName": record.original_name,
  236. "fileUrl": record.url,
  237. "fileSize": record.file_size,
  238. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  239. "fileType": record.file_type,
  240. }
  241. )
  242. except Exception as exc:
  243. db.rollback()
  244. return error_response(500, f"上传失败: {str(exc)}")