518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""运输计划 · 入库单 / 出库单管理端 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")}
|