| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- """运输计划 · 入库单 / 出库单管理端 API"""
- from __future__ import annotations
- from datetime import datetime
- from decimal import Decimal
- from typing import Any, List, Optional
- from pydantic import BaseModel, Field
- from fastapi import APIRouter, Depends, HTTPException, Query, Request
- 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.utils.excel_export import (
- EXPORT_MAX_ROWS,
- ExportColumn,
- ExportValidationError,
- export_rows_to_xlsx_response,
- filter_export_columns,
- )
- 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_list_filters import build_io_order_column_filters
- from app.services.pl_io_order_service import (
- dump_feedback_images,
- order_header_to_dict,
- update_io_order_items_qty_and_brand,
- )
- 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 _param_get(params: dict, *keys: str) -> Any:
- for key in keys:
- if key in params and params[key] not in (None, ""):
- return params[key]
- return None
- def _merge_query(request: Request, **known) -> dict:
- out = {k: v for k, v in known.items() if v is not None and v != ""}
- for k, v in request.query_params.multi_items():
- if k in ("page", "pageSize"):
- continue
- if k not in out and v not in (None, ""):
- out[k] = v
- return out
- def _body_to_params(body: BaseModel) -> dict:
- data = body.model_dump(by_alias=True, exclude_none=True)
- for key in ("ids", "columns"):
- data.pop(key, None)
- extra = getattr(body, "model_extra", None) or {}
- for key, value in extra.items():
- if key not in data and value not in (None, ""):
- data[key] = value
- return data
- def _list_orders(
- *,
- kind: str,
- order_no: Optional[str],
- plan_no: Optional[str],
- status: Optional[str],
- status_in: Optional[str],
- partner_company: Optional[str],
- vehicle_no: Optional[str],
- q: Optional[str],
- plan_date_from: Optional[str],
- plan_date_to: Optional[str],
- page: int,
- page_size: int,
- db: Session,
- user: DbUserContext,
- ids: Optional[list] = None,
- max_page_size: Optional[int] = None,
- ) -> 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)
- column_filters = build_io_order_column_filters(
- header_m,
- order_no=order_no,
- plan_no=plan_no,
- status=status,
- status_in=status_in,
- partner_company=partner_company,
- vehicle_no=vehicle_no,
- plan_date_from=plan_date_from,
- plan_date_to=plan_date_to,
- )
- if column_filters:
- query = query.filter(*column_filters)
- 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),
- )
- )
- if ids:
- query = query.filter(header_m.id.in_(ids))
- total = query.count()
- page = max(1, page)
- cap = int(max_page_size) if max_page_size else 500
- page_size = min(max(1, page_size), cap)
- 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}
- qty_map: dict[str, Decimal] = {}
- brand_qty_map: dict[str, list[dict]] | None = None
- for patch in payload.items:
- row = by_id.get(patch.id)
- if not row:
- continue
- qty_map[patch.id] = Decimal(str(patch.actual_qty))
- if patch.brand_qtys is not None:
- if brand_qty_map is None:
- brand_qty_map = {}
- brand_qty_map[patch.id] = [
- b.model_dump(by_alias=True) for b in patch.brand_qtys
- ]
- effective_status = payload.status if payload.status is not None else header.status
- try:
- update_io_order_items_qty_and_brand(
- db,
- kind=kind,
- tenant_no=tenant_no,
- order_id=header.id,
- items=items,
- item_qty=qty_map,
- item_brand_qtys=brand_qty_map,
- enforce_brand=(effective_status == "completed"),
- user_id=user.id,
- )
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- 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(
- request: Request,
- 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),
- plan_date_from: Optional[str] = Query(default=None, alias="planDateFrom"),
- plan_date_to: Optional[str] = Query(default=None, alias="planDateTo"),
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> IoOrderListResponse:
- params = _merge_query(
- request,
- orderNo=order_no,
- planNo=plan_no,
- status=status,
- partnerCompany=partner_company,
- vehicleNo=vehicle_no,
- planDateFrom=plan_date_from,
- planDateTo=plan_date_to,
- )
- return _list_orders(
- kind="inbound",
- order_no=_param_get(params, "orderNo", "order_no"),
- plan_no=_param_get(params, "planNo", "plan_no"),
- status=_param_get(params, "status"),
- status_in=_param_get(params, "statusIn", "status_in"),
- partner_company=_param_get(params, "partnerCompany", "partner_company"),
- vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"),
- q=q,
- plan_date_from=_param_get(params, "planDateFrom", "plan_date_from"),
- plan_date_to=_param_get(params, "planDateTo", "plan_date_to"),
- page=page,
- page_size=page_size,
- db=db,
- user=user,
- )
- IO_ORDER_EXPORT_COLUMNS = [
- ExportColumn(title="单号", field="orderNo"),
- ExportColumn(title="状态", field="status"),
- ExportColumn(title="计划号", field="planNo"),
- ExportColumn(title="往来单位", field="partnerCompany"),
- ExportColumn(title="车牌", field="vehicleNo"),
- ExportColumn(title="取货地址", field="pickupAddress"),
- ExportColumn(title="送货地址", field="destinationAddress"),
- ExportColumn(title="计划日期", field="planDate"),
- ExportColumn(title="实际数量合计", field="actualQtyTotal"),
- ExportColumn(title="最近提交", field="siteFeedbackTime"),
- ]
- class IoOrderExportBody(BaseModel):
- order_no: Optional[str] = Field(default=None, alias="orderNo")
- plan_no: Optional[str] = Field(default=None, alias="planNo")
- status: Optional[str] = None
- partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
- vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo")
- q: Optional[str] = None
- plan_date_from: Optional[str] = Field(default=None, alias="planDateFrom")
- plan_date_to: Optional[str] = Field(default=None, alias="planDateTo")
- ids: Optional[List[str]] = None
- columns: Optional[List[str]] = None
- model_config = {"populate_by_name": True, "extra": "allow"}
- def _export_orders(kind: str, body: IoOrderExportBody, db: Session, user: DbUserContext, sheet: str):
- try:
- tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
- params = _body_to_params(body)
- resp = _list_orders(
- kind=kind,
- order_no=_param_get(params, "orderNo", "order_no"),
- plan_no=_param_get(params, "planNo", "plan_no"),
- status=_param_get(params, "status"),
- status_in=_param_get(params, "statusIn", "status_in"),
- partner_company=_param_get(params, "partnerCompany", "partner_company"),
- vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"),
- q=_param_get(params, "q"),
- plan_date_from=_param_get(params, "planDateFrom", "plan_date_from"),
- plan_date_to=_param_get(params, "planDateTo", "plan_date_to"),
- page=1,
- page_size=EXPORT_MAX_ROWS,
- db=db,
- user=user,
- ids=body.ids,
- max_page_size=EXPORT_MAX_ROWS,
- )
- rows = []
- for item in resp.list:
- data = item.model_dump(by_alias=True)
- for k, v in list(data.items()):
- if hasattr(v, 'isoformat'):
- data[k] = str(v)[:19]
- if not data.get('destinationAddress'):
- data['destinationAddress'] = data.get('destinationName') or ''
- rows.append(data)
- cols = filter_export_columns(IO_ORDER_EXPORT_COLUMNS, body.columns)
- date_str = datetime.now().strftime("%Y%m%d")
- return export_rows_to_xlsx_response(
- db, tenant_no=tenant_no, sheet_title=sheet, columns=cols, rows=rows,
- filename_stem=f"{sheet}_{date_str}",
- )
- except ExportValidationError as e:
- raise HTTPException(status_code=400, detail=str(e)) from e
- @inbound_router.post("/export")
- def export_inbound(
- body: IoOrderExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- return _export_orders("inbound", body, db, user, "入库单")
- @outbound_router.post("/export")
- def export_outbound(
- body: IoOrderExportBody,
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ):
- return _export_orders("outbound", body, db, 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(
- request: Request,
- 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),
- plan_date_from: Optional[str] = Query(default=None, alias="planDateFrom"),
- plan_date_to: Optional[str] = Query(default=None, alias="planDateTo"),
- page: int = Query(default=1, ge=1),
- page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"),
- db: Session = Depends(get_db),
- user: DbUserContext = Depends(get_db_current_user),
- ) -> IoOrderListResponse:
- params = _merge_query(
- request,
- orderNo=order_no,
- planNo=plan_no,
- status=status,
- partnerCompany=partner_company,
- vehicleNo=vehicle_no,
- planDateFrom=plan_date_from,
- planDateTo=plan_date_to,
- )
- return _list_orders(
- kind="outbound",
- order_no=_param_get(params, "orderNo", "order_no"),
- plan_no=_param_get(params, "planNo", "plan_no"),
- status=_param_get(params, "status"),
- status_in=_param_get(params, "statusIn", "status_in"),
- partner_company=_param_get(params, "partnerCompany", "partner_company"),
- vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"),
- q=q,
- plan_date_from=_param_get(params, "planDateFrom", "plan_date_from"),
- plan_date_to=_param_get(params, "planDateTo", "plan_date_to"),
- 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")}
|