Просмотр исходного кода

feat(pl): 出入库 API 接入 enrichment 并扩展 schema

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
f5a0931471

+ 323 - 0
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")}

+ 165 - 0
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)

+ 98 - 0
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}

+ 6 - 1
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()