Files
shanxia2.0/backend/app/api/pl/public_io_order.py
T
2026-08-27 14:23:18 +08:00

516 lines
19 KiB
Python

"""运输计划 · 入库/出库单现场免登录公共 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)