"""运输计划 · 入库/出库单现场免登录公共 API""" from __future__ import annotations import os from decimal import Decimal 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 ( PlInboundOrder, PlInboundOrderItem, PlOutboundOrder, PlOutboundOrderItem, PlPlan, ) from app.schemas.pl.io_order import PublicIoOrderFeedback from app.services.pl_io_order_service import apply_site_feedback, order_header_to_dict, parse_feedback_images inbound_public_router = APIRouter(prefix="/public/pl/inbound", tags=["运输计划-入库单公共"]) outbound_public_router = APIRouter(prefix="/public/pl/outbound", tags=["运输计划-出库单公共"]) def _models(kind: str): if kind == "inbound": return PlInboundOrder, PlInboundOrderItem return PlOutboundOrder, PlOutboundOrderItem def _get_by_token(db: Session, kind: str, token: str): header_m, item_m = _models(kind) header = ( db.query(header_m) .filter(header_m.site_access_token == token, header_m.del_flag == 0) .first() ) if not header: raise HTTPException(status_code=404, detail="链接无效或已失效") items = ( db.query(item_m) .filter(item_m.order_id == header.id, item_m.del_flag == 0) .order_by(item_m.order_num.asc()) .all() ) return header, items def _resolve_io_order_link_flags(plan: Optional[PlPlan], header) -> tuple[bool, bool]: """返回 (作废, 归档)。现场链接实际走入库/出库单公共页。""" voided = header.status == "voided" or bool(plan and plan.follow_status == "cancelled") archived = bool(plan and plan.archive_status == "archived") return voided, archived def _build_page(db: Session, kind: str, header, items) -> dict: plan = db.query(PlPlan).filter(PlPlan.id == header.plan_id, PlPlan.del_flag == 0).first() voided, archived = _resolve_io_order_link_flags(plan, header) data = order_header_to_dict( kind, header, items, db=db, public_unit_as_label=True ) data["voided"] = voided data["archived"] = archived data["title"] = "入库单" if kind == "inbound" else "出库单" return data 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" def _make_handlers(kind: str): def get_page(token: str, db: Session = Depends(get_db)): header, items = _get_by_token(db, kind, token) return success_response(_build_page(db, kind, header, items)) def save_feedback(token: str, payload: PublicIoOrderFeedback, db: Session = Depends(get_db)): header, items = _get_by_token(db, kind, token) page = _build_page(db, kind, header, items) if page["voided"]: return error_response(400, "单据已作废,无法提交") if page["archived"]: return error_response(400, "运输计划已归档,无法提交") qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items} brand_qty_map: dict[str, list[dict]] | None = None if any(x.brand_qtys is not None for x in payload.items): brand_qty_map = {} for x in payload.items: if x.brand_qtys is not None: brand_qty_map[x.id] = [ b.model_dump(by_alias=True) for b in x.brand_qtys ] images = payload.images if not images: images = parse_feedback_images(header.site_feedback_images) try: apply_site_feedback( db, kind=kind, header=header, items=items, item_qty=qty_map, remark=payload.remark, images=images, status=payload.status, item_brand_qtys=brand_qty_map, enforce_brand=(payload.status == "completed"), ) except ValueError as exc: return error_response(400, str(exc)) db.commit() return success_response({"success": True, "message": "保存成功"}) def upload_attachment( token: str, file: UploadFile = File(...), folder: Optional[str] = Form(""), db: Session = Depends(get_db), ): header, _items = _get_by_token(db, kind, token) page = _build_page(db, kind, header, _items) 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_io_order_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=header.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_io_order_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)}") get_page.__name__ = f"get_public_{kind}_order" save_feedback.__name__ = f"save_public_{kind}_feedback" upload_attachment.__name__ = f"upload_public_{kind}_attachment" return get_page, save_feedback, upload_attachment _in_get, _in_fb, _in_up = _make_handlers("inbound") _out_get, _out_fb, _out_up = _make_handlers("outbound") inbound_public_router.get("/{token}")(_in_get) inbound_public_router.post("/{token}/feedback")(_in_fb) inbound_public_router.post("/{token}/attachments")(_in_up) outbound_public_router.get("/{token}")(_out_get) outbound_public_router.post("/{token}/feedback")(_out_fb) outbound_public_router.post("/{token}/attachments")(_out_up)