From f5a0931471a32aa5189fc4829e0b38e8c307f9c4 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Sun, 19 Jul 2026 16:43:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(pl):=20=E5=87=BA=E5=85=A5=E5=BA=93=20API?= =?UTF-8?q?=20=E6=8E=A5=E5=85=A5=20enrichment=20=E5=B9=B6=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/app/api/pl/io_order.py | 323 ++++++++++++++++++++ backend/app/api/pl/public_io_order.py | 165 ++++++++++ backend/app/schemas/pl/io_order.py | 98 ++++++ backend/app/services/pl_io_order_service.py | 7 +- 4 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/pl/io_order.py create mode 100644 backend/app/api/pl/public_io_order.py create mode 100644 backend/app/schemas/pl/io_order.py diff --git a/backend/app/api/pl/io_order.py b/backend/app/api/pl/io_order.py new file mode 100644 index 000000000..1ce10e089 --- /dev/null +++ b/backend/app/api/pl/io_order.py @@ -0,0 +1,323 @@ +"""运输计划 · 入库单 / 出库单管理端 API""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.db_auth import DbUserContext, get_db_current_user +from app.core.tenant_context import get_tenant_no +from app.models.pl_models import ( + PlInboundOrder, + PlInboundOrderItem, + PlOutboundOrder, + PlOutboundOrderItem, +) +from app.schemas.pl.io_order import IoOrderListResponse, IoOrderPatch, IoOrderRead +from app.services.pl_io_order_service import ( + dump_feedback_images, + order_header_to_dict, +) + +inbound_router = APIRouter(prefix="/inbound", tags=["运输计划-入库单"]) +outbound_router = APIRouter(prefix="/outbound", tags=["运输计划-出库单"]) + +_STATUS_OK = {"in_progress", "completed", "voided"} + + +def _models(kind: str): + if kind == "inbound": + return PlInboundOrder, PlInboundOrderItem + return PlOutboundOrder, PlOutboundOrderItem + + +def _get_order(db: Session, tenant_no: str, kind: str, order_id: str): + header_m, item_m = _models(kind) + header = ( + db.query(header_m) + .filter(header_m.id == order_id, header_m.tenant_no == tenant_no, 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(), item_m.create_time.asc()) + .all() + ) + return header, items + + +def _to_read(kind: str, header, items, db: Session) -> IoOrderRead: + return IoOrderRead.model_validate( + order_header_to_dict(kind, header, items, db=db) + ) + + +def _list_orders( + *, + kind: str, + order_no: Optional[str], + plan_no: Optional[str], + status: Optional[str], + partner_company: Optional[str], + vehicle_no: Optional[str], + q: Optional[str], + page: int, + page_size: int, + db: Session, + user: DbUserContext, +) -> IoOrderListResponse: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header_m, item_m = _models(kind) + query = db.query(header_m).filter(header_m.tenant_no == tenant_no, header_m.del_flag == 0) + if order_no: + query = query.filter(header_m.order_no.contains(order_no)) + if plan_no: + query = query.filter(header_m.plan_no.contains(plan_no)) + if status: + query = query.filter(header_m.status == status) + if partner_company: + query = query.filter(header_m.partner_company.contains(partner_company)) + if vehicle_no: + query = query.filter(header_m.vehicle_no.contains(vehicle_no)) + if q: + query = query.filter( + or_( + header_m.order_no.contains(q), + header_m.plan_no.contains(q), + header_m.partner_company.contains(q), + header_m.vehicle_no.contains(q), + ) + ) + total = query.count() + rows = ( + query.order_by(header_m.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + result: list[IoOrderRead] = [] + for row in rows: + items = ( + db.query(item_m) + .filter(item_m.order_id == row.id, item_m.del_flag == 0) + .order_by(item_m.order_num.asc()) + .all() + ) + result.append(_to_read(kind, row, items, db)) + return IoOrderListResponse(total=total, list=result) + + +def _patch_order( + *, + kind: str, + order_id: str, + payload: IoOrderPatch, + db: Session, + user: DbUserContext, +) -> IoOrderRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, items = _get_order(db, tenant_no, kind, order_id) + if payload.status is not None: + if payload.status not in _STATUS_OK: + raise HTTPException(status_code=400, detail="无效状态") + header.status = payload.status + if payload.site_feedback_remark is not None: + header.site_feedback_remark = (payload.site_feedback_remark or "").strip() or None + if payload.site_feedback_images is not None: + header.site_feedback_images = dump_feedback_images(payload.site_feedback_images) + header.site_feedback_time = datetime.now() + if payload.remark is not None: + header.remark = payload.remark + if payload.items: + by_id = {it.id: it for it in items} + for patch in payload.items: + row = by_id.get(patch.id) + if not row: + continue + row.actual_qty = Decimal(str(patch.actual_qty)) + row.qty_source = "manual" + row.update_by = user.id + header.update_by = user.id + db.commit() + header, items = _get_order(db, tenant_no, kind, order_id) + return _to_read(kind, header, items, db) + + +def _get_by_plan(db: Session, tenant_no: str, kind: str, plan_id: str): + """按计划查找入库/出库单;未落单时返回 (None, []),与运费 by-transport 一致。""" + header_m, item_m = _models(kind) + header = ( + db.query(header_m) + .filter( + header_m.tenant_no == tenant_no, + header_m.plan_id == plan_id, + header_m.del_flag == 0, + ) + .order_by(header_m.create_time.desc()) + .first() + ) + if not header: + return None, [] + items = ( + db.query(item_m) + .filter(item_m.order_id == header.id, item_m.del_flag == 0) + .order_by(item_m.order_num.asc(), item_m.create_time.asc()) + .all() + ) + return header, items + + +@inbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead]) +def get_inbound_by_plan( + plan_id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> Optional[IoOrderRead]: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, items = _get_by_plan(db, tenant_no, "inbound", plan_id) + if not header: + return None + return _to_read("inbound", header, items, db) + + +@inbound_router.get("/", response_model=IoOrderListResponse) +def list_inbound( + order_no: Optional[str] = Query(default=None, alias="orderNo"), + plan_no: Optional[str] = Query(default=None, alias="planNo"), + status: Optional[str] = Query(default=None), + partner_company: Optional[str] = Query(default=None, alias="partnerCompany"), + vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"), + q: Optional[str] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderListResponse: + return _list_orders( + kind="inbound", + order_no=order_no, + plan_no=plan_no, + status=status, + partner_company=partner_company, + vehicle_no=vehicle_no, + q=q, + page=page, + page_size=page_size, + db=db, + user=user, + ) + + +@inbound_router.get("/{id}", response_model=IoOrderRead) +def get_inbound( + id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, items = _get_order(db, tenant_no, "inbound", id) + return _to_read("inbound", header, items, db) + + +@inbound_router.patch("/{id}", response_model=IoOrderRead) +def patch_inbound( + id: str, + payload: IoOrderPatch, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderRead: + return _patch_order(kind="inbound", order_id=id, payload=payload, db=db, user=user) + + +@inbound_router.get("/{id}/site-url") +def inbound_site_url( + id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> dict: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, _ = _get_order(db, tenant_no, "inbound", id) + data = order_header_to_dict("inbound", header, None) + return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")} + + +@outbound_router.get("/by-plan/{plan_id}", response_model=Optional[IoOrderRead]) +def get_outbound_by_plan( + plan_id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> Optional[IoOrderRead]: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, items = _get_by_plan(db, tenant_no, "outbound", plan_id) + if not header: + return None + return _to_read("outbound", header, items, db) + + +@outbound_router.get("/", response_model=IoOrderListResponse) +def list_outbound( + order_no: Optional[str] = Query(default=None, alias="orderNo"), + plan_no: Optional[str] = Query(default=None, alias="planNo"), + status: Optional[str] = Query(default=None), + partner_company: Optional[str] = Query(default=None, alias="partnerCompany"), + vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo"), + q: Optional[str] = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderListResponse: + return _list_orders( + kind="outbound", + order_no=order_no, + plan_no=plan_no, + status=status, + partner_company=partner_company, + vehicle_no=vehicle_no, + q=q, + page=page, + page_size=page_size, + db=db, + user=user, + ) + + +@outbound_router.get("/{id}", response_model=IoOrderRead) +def get_outbound( + id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, items = _get_order(db, tenant_no, "outbound", id) + return _to_read("outbound", header, items, db) + + +@outbound_router.patch("/{id}", response_model=IoOrderRead) +def patch_outbound( + id: str, + payload: IoOrderPatch, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> IoOrderRead: + return _patch_order(kind="outbound", order_id=id, payload=payload, db=db, user=user) + + +@outbound_router.get("/{id}/site-url") +def outbound_site_url( + id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> dict: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + header, _ = _get_order(db, tenant_no, "outbound", id) + data = order_header_to_dict("outbound", header, None) + return {"siteUrl": data.get("siteUrl"), "siteAccessToken": data.get("siteAccessToken")} diff --git a/backend/app/api/pl/public_io_order.py b/backend/app/api/pl/public_io_order.py new file mode 100644 index 000000000..4c4a30b00 --- /dev/null +++ b/backend/app/api/pl/public_io_order.py @@ -0,0 +1,165 @@ +"""运输计划 · 入库/出库单现场免登录公共 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 _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 = header.status == "voided" or bool(plan and plan.follow_status == "cancelled") + data = order_header_to_dict( + kind, header, items, db=db, public_unit_as_label=True + ) + data["voided"] = voided + 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, "单据已作废,无法提交") + qty_map = {x.id: Decimal(str(x.actual_qty)) for x in payload.items} + images = payload.images + if not images: + images = parse_feedback_images(header.site_feedback_images) + try: + apply_site_feedback( + db, + header=header, + items=items, + item_qty=qty_map, + remark=payload.remark, + images=images, + ) + 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 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) diff --git a/backend/app/schemas/pl/io_order.py b/backend/app/schemas/pl/io_order.py new file mode 100644 index 000000000..129778951 --- /dev/null +++ b/backend/app/schemas/pl/io_order.py @@ -0,0 +1,98 @@ +"""入库单 / 出库单 Pydantic schemas""" +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field + + +class IoOrderItemRead(BaseModel): + id: str + order_id: str = Field(alias="orderId") + plan_product_id: str = Field(alias="planProductId") + product_id: Optional[str] = Field(default=None, alias="productId") + product_name: Optional[str] = Field(default=None, alias="productName") + specification: Optional[str] = None + unit: Optional[str] = None + plan_qty: Optional[Decimal] = Field(default=None, alias="planQty") + actual_qty: Optional[Decimal] = Field(default=None, alias="actualQty") + qty_source: Optional[str] = Field(default=None, alias="qtySource") + product_no: Optional[str] = Field(default=None, alias="productNo") + package_spec: Optional[str] = Field(default=None, alias="packageSpec") + remark: Optional[str] = None + unit_label: Optional[str] = Field(default=None, alias="unitLabel") + model_config = {"populate_by_name": True} + + +class IoOrderItemPatch(BaseModel): + id: str + actual_qty: Decimal = Field(alias="actualQty") + model_config = {"populate_by_name": True} + + +class IoOrderPatch(BaseModel): + status: Optional[str] = None + site_feedback_remark: Optional[str] = Field(default=None, alias="siteFeedbackRemark") + site_feedback_images: Optional[list[str]] = Field(default=None, alias="siteFeedbackImages") + remark: Optional[str] = None + items: Optional[list[IoOrderItemPatch]] = None + model_config = {"populate_by_name": True} + + +class IoOrderRead(BaseModel): + id: str + order_no: str = Field(alias="orderNo") + plan_id: str = Field(alias="planId") + transport_id: str = Field(alias="transportId") + status: str + partner_company: Optional[str] = Field(default=None, alias="partnerCompany") + vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo") + plan_no: Optional[str] = Field(default=None, alias="planNo") + plan_date: Optional[date] = Field(default=None, alias="planDate") + site_access_token: Optional[str] = Field(default=None, alias="siteAccessToken") + site_url: Optional[str] = Field(default=None, alias="siteUrl") + site_feedback_remark: Optional[str] = Field(default=None, alias="siteFeedbackRemark") + site_feedback_images: list[str] = Field(default_factory=list, alias="siteFeedbackImages") + site_feedback_time: Optional[datetime] = Field(default=None, alias="siteFeedbackTime") + site_first_submitted_at: Optional[datetime] = Field(default=None, alias="siteFirstSubmittedAt") + remark: Optional[str] = None + product_summary: Optional[str] = Field(default=None, alias="productSummary") + actual_qty_total: Optional[float] = Field(default=None, alias="actualQtyTotal") + kind: str + items: list[IoOrderItemRead] = Field(default_factory=list) + business_type: Optional[str] = Field(default=None, alias="businessType") + plan_remark: Optional[str] = Field(default=None, alias="planRemark") + pickup_address: Optional[str] = Field(default=None, alias="pickupAddress") + pickup_lng: Optional[float] = Field(default=None, alias="pickupLng") + pickup_lat: Optional[float] = Field(default=None, alias="pickupLat") + pickup_time: Optional[datetime] = Field(default=None, alias="pickupTime") + pickup_notes: Optional[str] = Field(default=None, alias="pickupNotes") + destination_name: Optional[str] = Field(default=None, alias="destinationName") + destination_address: Optional[str] = Field(default=None, alias="destinationAddress") + destination_lng: Optional[float] = Field(default=None, alias="destinationLng") + destination_lat: Optional[float] = Field(default=None, alias="destinationLat") + delivery_time: Optional[datetime] = Field(default=None, alias="deliveryTime") + destination_notes: Optional[str] = Field(default=None, alias="destinationNotes") + create_time: Optional[datetime] = Field(default=None, alias="createTime") + update_time: Optional[datetime] = Field(default=None, alias="updateTime") + model_config = {"populate_by_name": True} + + +class IoOrderListResponse(BaseModel): + total: int + list: list[IoOrderRead] + + +class PublicIoOrderItemFeedback(BaseModel): + id: str + actual_qty: Decimal = Field(alias="actualQty") + model_config = {"populate_by_name": True} + + +class PublicIoOrderFeedback(BaseModel): + remark: Optional[str] = None + images: list[str] = Field(default_factory=list) + items: list[PublicIoOrderItemFeedback] = Field(default_factory=list) + model_config = {"populate_by_name": True} diff --git a/backend/app/services/pl_io_order_service.py b/backend/app/services/pl_io_order_service.py index 601aed388..651653a5b 100644 --- a/backend/app/services/pl_io_order_service.py +++ b/backend/app/services/pl_io_order_service.py @@ -417,7 +417,11 @@ def enrich_io_order_dict( if plan_id: plan = ( db.query(PlPlan) - .filter(PlPlan.id == plan_id, PlPlan.del_flag == 0) + .filter( + PlPlan.id == plan_id, + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + ) .first() ) merge_plan_logistics_into_order(data, plan) @@ -441,6 +445,7 @@ def enrich_io_order_dict( db.query(PlPlanProduct) .filter( PlPlanProduct.id.in_(product_ids), + PlPlanProduct.tenant_no == tenant_no, PlPlanProduct.del_flag == 0, ) .all()