"""运输计划 · 工厂现场人员免登录公共 API(pl_transport_site_link)""" from __future__ import annotations import json import os from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from sqlalchemy.orm import Session from app.core.attachment_biz import resolve_upload_folder from app.core.config import settings from app.core.database import get_db from app.core.response import error_response, success_response from app.core.storage import storage from app.crud import oss as oss_svc from app.models.pl_models import PlPlan, PlPlanProduct, PlTransportSiteLink from app.schemas.db.public_inventory import PublicInventoryFeedback from app.schemas.pl.transport import PublicTransportPlanProductRead, PublicTransportSitePageRead from app.services.db.mappers import inventory_date_to_api from app.services.pl_product_display import ( load_crm_product_alias_maps, resolve_product_display_names, ) from app.utils.excel_export import load_dict_label_maps router = APIRouter(prefix="/public/pl/site", tags=["运输计划-现场公共"]) CRM_PRODUCT_UNIT = "crm_product_unit" def _parse_feedback_images(raw: Optional[str]) -> list[str]: if not raw: return [] try: parsed = json.loads(raw) if isinstance(parsed, list): return [str(item) for item in parsed if item] except (TypeError, json.JSONDecodeError): pass return [part.strip() for part in raw.split(",") if part.strip()] def _get_site_link_by_token(db: Session, token: str) -> PlTransportSiteLink: row = ( db.query(PlTransportSiteLink) .filter( PlTransportSiteLink.site_access_token == token, PlTransportSiteLink.del_flag == 0, ) .first() ) if not row: raise HTTPException(status_code=404, detail="链接无效或已失效") return row def _resolve_site_link_flags(plan: Optional[PlPlan]) -> tuple[bool, bool]: """返回 (作废, 归档)。""" voided = bool(plan and plan.follow_status == "cancelled") archived = bool(plan and plan.archive_status == "archived") return voided, archived def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str: code = (unit_code or "").strip() if not code: return "吨" return unit_map.get(code, code) def _resolve_plan_product(db: Session, link: PlTransportSiteLink) -> Optional[PlPlanProduct]: if not link.plan_product_id: return None return ( db.query(PlPlanProduct) .filter( PlPlanProduct.id == link.plan_product_id, PlPlanProduct.del_flag == 0, ) .first() ) def _build_site_product_read( db: Session, link: PlTransportSiteLink, plan_product: Optional[PlPlanProduct], ) -> PublicTransportPlanProductRead: unit_map = load_dict_label_maps( db, [CRM_PRODUCT_UNIT], tenant_no=link.tenant_no ).get(CRM_PRODUCT_UNIT, {}) quantity = link.quantity unit = "吨" remark = None product_id = "" saved_name = (link.product or "").strip() brand_mode = None brand_id = None brand_name = None plan_product_id = "" if plan_product: plan_product_id = plan_product.id or "" product_id = (plan_product.product_id or "").strip() saved_name = (plan_product.product_name or saved_name or "").strip() quantity = ( plan_product.quantity or plan_product.inbound_estimate_qty or plan_product.outbound_estimate_qty or link.quantity ) unit = _resolve_unit_label(unit_map, plan_product.unit) remark = (plan_product.remark or "").strip() or None brand_mode = plan_product.brand_mode brand_id = plan_product.brand_id brand_name = plan_product.brand_name standard_by_id, alias_options_by_id = load_crm_product_alias_maps( db, tenant_no=link.tenant_no, product_ids={product_id} if product_id else set(), ) display_name, alias_name = resolve_product_display_names( saved_product_name=saved_name or link.product, product_id=product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) return PublicTransportPlanProductRead( plan_product_id=plan_product_id, product_name=display_name or (link.product or ""), alias_name=alias_name, model=link.specification or (plan_product.model if plan_product else None), package_spec=plan_product.package_spec if plan_product else None, unit=unit, quantity=quantity, remark=remark, brand_mode=brand_mode, brand_id=brand_id, brand_name=brand_name, ) def _build_public_page(db: Session, link: PlTransportSiteLink) -> PublicTransportSitePageRead: plan = ( db.query(PlPlan) .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0) .first() ) voided, archived = _resolve_site_link_flags(plan) plan_product = _resolve_plan_product(db, link) product_read = _build_site_product_read(db, link, plan_product) return PublicTransportSitePageRead( business_type=link.business_type, date=inventory_date_to_api(link.plan_date) or None, partner_company=link.partner_company or "", vehicle_no=link.vehicle_no or "", plan_no=link.plan_no, remark=link.remark, pickup_address=plan.pickup_address if plan else None, pickup_lng=plan.pickup_lng if plan else None, pickup_lat=plan.pickup_lat if plan else None, pickup_time=plan.pickup_time if plan else None, pickup_contact=plan.pickup_contact if plan else None, pickup_notes=plan.pickup_notes if plan else None, destination_name=plan.destination_name if plan else None, destination_address=( (plan.destination_address or plan.destination_name) if plan else None ), destination_lng=plan.destination_lng if plan else None, destination_lat=plan.destination_lat if plan else None, delivery_time=plan.delivery_time if plan else None, delivery_contact=plan.delivery_contact if plan else None, destination_notes=plan.destination_notes if plan else None, products=[product_read], site_feedback_remark=link.site_feedback_remark, site_feedback_images=_parse_feedback_images(link.site_feedback_images), site_feedback_time=link.site_feedback_time, voided=voided, archived=archived, ) @router.get("/{token}") def get_public_site(token: str, db: Session = Depends(get_db)): link = _get_site_link_by_token(db, token) page = _build_public_page(db, link) return success_response(page.model_dump(by_alias=True)) @router.post("/{token}/feedback") def save_public_site_feedback( token: str, payload: PublicInventoryFeedback, db: Session = Depends(get_db), ): link = _get_site_link_by_token(db, token) page = _build_public_page(db, link) if page.voided: return error_response(400, "运输计划已作废,无法提交回执") if page.archived: return error_response(400, "运输计划已归档,无法提交回执") if payload.site_feedback_images is not None: link.site_feedback_images = json.dumps(payload.site_feedback_images) if payload.site_feedback_remark is not None: link.site_feedback_remark = payload.site_feedback_remark link.site_feedback_time = datetime.now() db.commit() return success_response({"success": True, "message": "保存成功"}) def _format_file_size(size_bytes: int) -> str: if size_bytes < 1024: return f"{size_bytes} B" if size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" return f"{size_bytes / (1024 * 1024):.1f} MB" @router.post("/{token}/attachments") def upload_public_site_attachment( token: str, file: UploadFile = File(...), folder: Optional[str] = Form(""), db: Session = Depends(get_db), ): link = _get_site_link_by_token(db, token) page = _build_public_page(db, link) if page.voided: return error_response(400, "运输计划已作废,无法上传附件") if page.archived: return error_response(400, "运输计划已归档,无法上传附件") if not file or not file.filename: return error_response(400, "请选择文件") try: effective_folder = resolve_upload_folder( folder=folder or "", biz_type="pl_transport_site_feedback" ) file_content = file.file.read() full_url = storage.upload_file(file_content, file.filename, effective_folder) base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else "" if base_url and full_url.startswith(base_url + "/"): file_url = full_url[len(base_url) + 1 :] else: file_url = full_url record = oss_svc.create_oss( db, tenant_no=link.tenant_no, url=file_url, original_name=file.filename, file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "", file_size=len(file_content), file_type=file.content_type or "", biz_type="pl_transport_site_feedback", user_id=None, ext1=effective_folder or None, ) db.commit() return success_response( { "id": record.id, "fileName": record.original_name, "fileUrl": record.url, "fileSize": record.file_size, "fileSizeDisplay": _format_file_size(record.file_size or 0), "fileType": record.file_type, } ) except Exception as exc: db.rollback() return error_response(500, f"上传失败: {str(exc)}")