"""运输计划 · 入库/出库单现场免登录公共 API""" from __future__ import annotations import os from datetime import date, datetime from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from sqlalchemy import and_, or_ 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, PlPlanProduct, ) from app.schemas.pl.io_order import ( PublicIoOrderFeedback, PublicIoOrderHistoryPageRead, PublicIoOrderHistoryProductRead, PublicIoOrderHistoryRecordRead, ) from app.services.pl_dibang_bridge_service import plan_has_load_stop from app.services.pl_io_order_service import ( apply_io_order_stop_site_feedbacks, apply_site_feedback, build_io_order_site_url, order_header_to_dict, parse_feedback_images, ) from app.services.pl_transport_stop_feedback_service import ( build_public_stop_reads_for_segments, load_io_order_site_stop_segments, ) 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 inbound_public_router = APIRouter(prefix="/public/pl/inbound", tags=["运输计划-入库单公共"]) outbound_public_router = APIRouter(prefix="/public/pl/outbound", tags=["运输计划-出库单公共"]) CRM_PRODUCT_UNIT = "crm_product_unit" 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 _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 _parse_history_date(raw: Optional[str]) -> Optional[date]: text = (raw or "").strip() if not text: return None try: return date.fromisoformat(text) except ValueError: return None def _history_status_label(voided: bool, archived: bool, status: Optional[str]) -> str: if voided or status == "voided": return "已作废" if archived: return "已归档" if status == "completed": return "已完成" return "进行中" def _query_kind_history( db: Session, *, kind: str, tenant_no: str, day: date, keyword: str, ) -> list[tuple]: """返回 [(create_time, PublicIoOrderHistoryRecordRead), ...]。""" header_m, item_m = _models(kind) query = ( db.query(header_m, PlPlan) .outerjoin( PlPlan, and_(PlPlan.id == header_m.plan_id, PlPlan.del_flag == 0), ) .filter( header_m.tenant_no == tenant_no, header_m.del_flag == 0, header_m.plan_date == day, ) ) kw = (keyword or "").strip() if kw: like = f"%{kw}%" product_match = ( db.query(item_m.id) .filter( item_m.order_id == header_m.id, item_m.del_flag == 0, item_m.product_name.like(like), ) .exists() ) query = query.filter( or_( header_m.vehicle_no.like(like), product_match, ) ) rows = query.order_by(header_m.create_time.desc()).all() out: list[tuple] = [] for header, plan in rows: voided, archived = _resolve_io_order_link_flags(plan, header) tok = (header.site_access_token or "").strip() out.append( ( header.create_time, PublicIoOrderHistoryRecordRead( kind=kind, order_id=header.id, order_no=header.order_no or "", plan_id=header.plan_id, plan_no=header.plan_no or (plan.plan_no if plan else None), plan_date=header.plan_date, partner_company=header.partner_company, supplier_name=plan.supplier_name if plan else None, customer_name=plan.customer_name if plan else None, vehicle_no=header.vehicle_no, pickup_time=plan.pickup_time if plan else None, delivery_time=plan.delivery_time if plan else None, pickup_address=plan.pickup_address if plan else None, destination_address=( (plan.destination_address or plan.destination_name) if plan else None ), site_access_token=tok or None, site_url=build_io_order_site_url(kind, tok) if tok else None, status=header.status, order_voided=voided, plan_archived=archived, status_label=_history_status_label(voided, archived, header.status), ), ) ) return out def _build_history_page( db: Session, *, entry_kind: str, token: str, date_raw: str, keyword: Optional[str], page: int = 1, page_size: int = 20, ) -> PublicIoOrderHistoryPageRead: header, _items = _get_by_token(db, entry_kind, token) day = _parse_history_date(date_raw) if day is None: raise HTTPException(status_code=400, detail="日期格式无效,请使用 YYYY-MM-DD") kw = (keyword or "").strip() merged = _query_kind_history( db, kind="inbound", tenant_no=header.tenant_no, day=day, keyword=kw ) + _query_kind_history( db, kind="outbound", tenant_no=header.tenant_no, day=day, keyword=kw ) merged.sort(key=lambda x: x[0] or datetime.min, reverse=True) total = len(merged) page = max(1, int(page or 1)) page_size = min(max(1, int(page_size or 20)), 500) start = (page - 1) * page_size page_rows = merged[start : start + page_size] records = [r for _, r in page_rows] _attach_history_products(db, tenant_no=header.tenant_no, records=records) return PublicIoOrderHistoryPageRead( date=day.isoformat(), keyword=kw or None, page=page, page_size=page_size, total=total, records=records, ) def _attach_history_products( db: Session, *, tenant_no: str, records: list[PublicIoOrderHistoryRecordRead], ) -> None: if not records: return inbound_ids = [r.order_id for r in records if r.kind == "inbound"] outbound_ids = [r.order_id for r in records if r.kind == "outbound"] items_by_order: dict[str, list] = {} if inbound_ids: for item in ( db.query(PlInboundOrderItem) .filter( PlInboundOrderItem.order_id.in_(inbound_ids), PlInboundOrderItem.del_flag == 0, ) .order_by(PlInboundOrderItem.order_num.asc(), PlInboundOrderItem.create_time.asc()) .all() ): items_by_order.setdefault(item.order_id, []).append(item) if outbound_ids: for item in ( db.query(PlOutboundOrderItem) .filter( PlOutboundOrderItem.order_id.in_(outbound_ids), PlOutboundOrderItem.del_flag == 0, ) .order_by(PlOutboundOrderItem.order_num.asc(), PlOutboundOrderItem.create_time.asc()) .all() ): items_by_order.setdefault(item.order_id, []).append(item) all_items = [it for items in items_by_order.values() for it in items] plan_product_ids = { (it.plan_product_id or "").strip() for it in all_items if (it.plan_product_id or "").strip() } plan_product_by_id: dict[str, PlPlanProduct] = {} if plan_product_ids: for prod in ( db.query(PlPlanProduct) .filter(PlPlanProduct.id.in_(list(plan_product_ids)), PlPlanProduct.del_flag == 0) .all() ): plan_product_by_id[prod.id] = prod crm_product_ids = { (it.product_id or "").strip() for it in all_items if (it.product_id or "").strip() } standard_by_id, alias_options_by_id = load_crm_product_alias_maps( db, tenant_no=tenant_no, product_ids=crm_product_ids ) unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get( CRM_PRODUCT_UNIT, {} ) for record in records: products: list[PublicIoOrderHistoryProductRead] = [] for item in items_by_order.get(record.order_id) or []: product_id = (item.product_id or "").strip() plan_product = plan_product_by_id.get((item.plan_product_id or "").strip()) display_name, _alias = resolve_product_display_names( saved_product_name=item.product_name, product_id=product_id, standard_by_id=standard_by_id, alias_options_by_id=alias_options_by_id, ) model = (item.specification or "").strip() or ( (plan_product.model or "").strip() if plan_product else "" ) or None package_spec = ( (plan_product.package_spec or "").strip() if plan_product else "" ) or None qty = item.actual_qty if item.actual_qty is not None else item.plan_qty products.append( PublicIoOrderHistoryProductRead( product_name=display_name or item.product_name or "—", model=model, package_spec=package_spec, quantity=qty, unit=_resolve_unit_label(unit_map, item.unit), ) ) record.products = products 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 "出库单" data["stops"] = [] if plan and plan_has_load_stop(db, header.tenant_no, plan.id): segments = load_io_order_site_stop_segments( db, tenant_no=header.tenant_no, plan_id=plan.id, transport_id=header.transport_id, ) data["stops"] = build_public_stop_reads_for_segments( db, tenant_no=header.tenant_no, plan_id=plan.id, segments=segments, ) 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 get_history( token: str, date: str = Query(..., description="筛选日 YYYY-MM-DD"), q: Optional[str] = Query(None, description="车牌号/产品名称模糊"), page: int = Query(1, ge=1, description="页码,从 1 开始"), page_size: int = Query(20, ge=1, le=500, alias="pageSize", description="每页条数"), db: Session = Depends(get_db), ): try: history_page = _build_history_page( db, entry_kind=kind, token=token, date_raw=date, keyword=q, page=page, page_size=page_size, ) except HTTPException as exc: if exc.status_code == 400: return error_response(400, str(exc.detail)) raise return success_response(history_page.model_dump(by_alias=True)) 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, "运输计划已归档,无法提交") use_multi_stop = bool(payload.stops) qty_map: dict[str, Decimal] = {} brand_qty_map: dict[str, list[dict]] | None = None images = payload.images try: if use_multi_stop: qty_map, brand_qty_map, merged_images = apply_io_order_stop_site_feedbacks( db, kind=kind, header=header, items=items, stops=[stop.model_dump(by_alias=True) for stop in payload.stops], enforce_brand=(payload.status == "completed"), ) images = merged_images if payload.items: for x in payload.items: qty_map[x.id] = Decimal(str(x.actual_qty)) if any(x.brand_qtys is not None for x in payload.items): brand_qty_map = brand_qty_map or {} 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 ] else: qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items} 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 ] if not images: images = parse_feedback_images(header.site_feedback_images) 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") and (not use_multi_stop or bool(payload.items)), ) 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" get_history.__name__ = f"get_public_{kind}_history" save_feedback.__name__ = f"save_public_{kind}_feedback" upload_attachment.__name__ = f"upload_public_{kind}_attachment" return get_page, get_history, save_feedback, upload_attachment _in_get, _in_hist, _in_fb, _in_up = _make_handlers("inbound") _out_get, _out_hist, _out_fb, _out_up = _make_handlers("outbound") inbound_public_router.get("/{token}/history")(_in_hist) 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}/history")(_out_hist) outbound_public_router.get("/{token}")(_out_get) outbound_public_router.post("/{token}/feedback")(_out_fb) outbound_public_router.post("/{token}/attachments")(_out_up)