516 lines
19 KiB
Python
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)
|