908 lines
33 KiB
Python
908 lines
33 KiB
Python
"""运输计划司机免登录公共 API"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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_, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.storage import storage
|
|
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.crud import oss as oss_svc
|
|
from app.models.crm_models import CrmProductBrand
|
|
from app.models.pl_models import (
|
|
PlPlan,
|
|
PlPlanDelivery,
|
|
PlPlanLoad,
|
|
PlPlanProduct,
|
|
PlPlanTransport,
|
|
PlTransport,
|
|
PlTransportProductBrandQty,
|
|
PlTransportProductFeedback,
|
|
)
|
|
from app.schemas.pl.io_order import BrandOption, BrandQtyItem
|
|
from app.schemas.pl.resource_snapshot import parse_resource_snapshot
|
|
from app.schemas.pl.transport import (
|
|
PublicTransportDriverStopFeedback,
|
|
PublicTransportFeedback,
|
|
PublicTransportHistoryPageRead,
|
|
PublicTransportHistoryProductRead,
|
|
PublicTransportHistoryRecordRead,
|
|
PublicTransportHistoryStopProductRead,
|
|
PublicTransportHistoryStopRead,
|
|
PublicTransportPageRead,
|
|
PublicTransportPersonnelRead,
|
|
PublicTransportPlanProductRead,
|
|
PublicTransportStopProductRead,
|
|
PublicTransportStopRead,
|
|
)
|
|
from app.services.pl_dibang_bridge_service import plan_has_load_stop
|
|
from app.services.pl_feedback_brand_qty import (
|
|
persist_transport_product_feedback,
|
|
require_brand_qtys_if_needed,
|
|
)
|
|
from app.services.pl_transport_stop_feedback_service import (
|
|
PublicStopProductRow,
|
|
load_driver_stop_segments,
|
|
save_driver_stop_feedback,
|
|
)
|
|
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
|
|
|
|
router = APIRouter(prefix="/public/pl/transport", 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 _build_stop_product_reads(
|
|
*,
|
|
stop_products: list[PublicStopProductRow],
|
|
plan_products_by_id: dict[str, PlPlanProduct],
|
|
unit_map: dict[str, str],
|
|
brand_options_by_product: dict[str, list[BrandOption]],
|
|
standard_by_id: dict[str, str],
|
|
alias_options_by_id: dict[str, list[str]],
|
|
) -> list[PublicTransportStopProductRead]:
|
|
reads: list[PublicTransportStopProductRead] = []
|
|
for row in stop_products:
|
|
plan_prod = plan_products_by_id.get(row.plan_product_id)
|
|
product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
|
|
display_name, alias_name = resolve_product_display_names(
|
|
saved_product_name=row.product_name,
|
|
product_id=product_id,
|
|
standard_by_id=standard_by_id,
|
|
alias_options_by_id=alias_options_by_id,
|
|
)
|
|
brand_qtys = None
|
|
if row.brand_qtys:
|
|
brand_qtys = [
|
|
BrandQtyItem(
|
|
brand_id=str(item.get("brandId") or item.get("brand_id") or ""),
|
|
brand_name=item.get("brandName") or item.get("brand_name"),
|
|
qty=item.get("qty") or 0,
|
|
)
|
|
for item in row.brand_qtys
|
|
if isinstance(item, dict)
|
|
] or None
|
|
reads.append(
|
|
PublicTransportStopProductRead(
|
|
stop_product_id=row.stop_product_id,
|
|
plan_product_id=row.plan_product_id,
|
|
product_name=display_name,
|
|
alias_name=alias_name,
|
|
model=row.model,
|
|
package_spec=row.package_spec,
|
|
unit=_resolve_unit_label(unit_map, row.unit),
|
|
quantity=row.plan_qty,
|
|
remark=row.remark,
|
|
brand_mode=plan_prod.brand_mode if plan_prod else None,
|
|
brand_id=plan_prod.brand_id if plan_prod else None,
|
|
brand_name=plan_prod.brand_name if plan_prod else None,
|
|
brand_options=brand_options_by_product.get(product_id) or None,
|
|
actual_qty=row.actual_qty,
|
|
brand_qtys=brand_qtys,
|
|
)
|
|
)
|
|
return reads
|
|
|
|
|
|
def _get_transport_by_token(db: Session, token: str) -> PlTransport:
|
|
transport = (
|
|
db.query(PlTransport)
|
|
.filter(
|
|
PlTransport.driver_access_token == token,
|
|
PlTransport.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not transport:
|
|
raise HTTPException(status_code=404, detail="链接无效或已失效")
|
|
return transport
|
|
|
|
|
|
def _parse_feedback_images(raw: Optional[str]) -> list[str]:
|
|
if not raw:
|
|
return []
|
|
try:
|
|
parsed = json.loads(raw)
|
|
if isinstance(parsed, list):
|
|
return [str(item) for item in parsed if item]
|
|
except (TypeError, json.JSONDecodeError):
|
|
pass
|
|
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
|
|
|
|
def _format_plan_date(value) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.strftime("%Y-%m-%d")
|
|
if isinstance(value, date):
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
def _normalize_main_plate(value: Optional[str]) -> str:
|
|
return (value or "").strip()
|
|
|
|
|
|
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 _as_date(value) -> Optional[date]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
if isinstance(value, date):
|
|
return value
|
|
return None
|
|
|
|
|
|
def _history_matches_date(pickup_time, delivery_time, day: date) -> bool:
|
|
return _as_date(pickup_time) == day or _as_date(delivery_time) == day
|
|
|
|
|
|
def _history_status_label(plan_voided: bool, plan_archived: bool) -> str:
|
|
if plan_voided:
|
|
return "已作废"
|
|
if plan_archived:
|
|
return "已归档"
|
|
return "进行中"
|
|
|
|
|
|
def _build_history_stop_reads(
|
|
db: Session,
|
|
*,
|
|
tenant_no: str,
|
|
plan: PlPlan,
|
|
transport: PlTransport,
|
|
) -> list[PublicTransportHistoryStopRead]:
|
|
if not plan_has_load_stop(db, tenant_no, plan.id):
|
|
return []
|
|
|
|
plan_products = (
|
|
db.query(PlPlanProduct)
|
|
.filter(
|
|
PlPlanProduct.plan_id == plan.id,
|
|
PlPlanProduct.tenant_no == tenant_no,
|
|
PlPlanProduct.del_flag == 0,
|
|
)
|
|
.all()
|
|
)
|
|
plan_products_by_id = {row.id: row for row in plan_products}
|
|
crm_product_ids = {
|
|
(row.product_id or "").strip() for row in plan_products if (row.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, {}
|
|
)
|
|
|
|
load_row = (
|
|
db.query(PlPlanLoad)
|
|
.filter(
|
|
PlPlanLoad.plan_id == plan.id,
|
|
PlPlanLoad.tenant_no == tenant_no,
|
|
PlPlanLoad.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
delivery_rows = (
|
|
db.query(PlPlanDelivery)
|
|
.filter(
|
|
PlPlanDelivery.plan_id == plan.id,
|
|
PlPlanDelivery.tenant_no == tenant_no,
|
|
PlPlanDelivery.del_flag == 0,
|
|
)
|
|
.all()
|
|
)
|
|
delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows}
|
|
load_time = load_row.load_time if load_row else None
|
|
|
|
segments = load_driver_stop_segments(
|
|
db,
|
|
tenant_no=tenant_no,
|
|
plan_id=plan.id,
|
|
transport_id=transport.id,
|
|
)
|
|
|
|
reads: list[PublicTransportHistoryStopRead] = []
|
|
for segment in segments:
|
|
stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id)
|
|
stop_products: list[PublicTransportHistoryStopProductRead] = []
|
|
for row in segment.products:
|
|
plan_prod = plan_products_by_id.get(row.plan_product_id)
|
|
product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
|
|
display_name, _alias_name = resolve_product_display_names(
|
|
saved_product_name=row.product_name,
|
|
product_id=product_id,
|
|
standard_by_id=standard_by_id,
|
|
alias_options_by_id=alias_options_by_id,
|
|
)
|
|
stop_products.append(
|
|
PublicTransportHistoryStopProductRead(
|
|
product_name=display_name or row.product_name or "—",
|
|
model=row.model,
|
|
package_spec=row.package_spec,
|
|
quantity=row.plan_qty,
|
|
unit=_resolve_unit_label(unit_map, row.unit),
|
|
)
|
|
)
|
|
reads.append(
|
|
PublicTransportHistoryStopRead(
|
|
stop_type=segment.stop_type,
|
|
seq=segment.seq,
|
|
label=segment.label,
|
|
customer_name=segment.customer_name,
|
|
address=segment.address,
|
|
stop_time=stop_time,
|
|
products=stop_products,
|
|
)
|
|
)
|
|
return reads
|
|
|
|
|
|
def _build_driver_url(token: str) -> str:
|
|
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
|
|
return f"{origin}/#/public/pl/transport/{token}"
|
|
|
|
|
|
def _resolve_driver_link_flags(
|
|
plan: Optional[PlPlan], transport: PlTransport
|
|
) -> tuple[bool, bool]:
|
|
"""返回 (作废, 归档)。"""
|
|
voided = (
|
|
(plan is not None and plan.follow_status == "cancelled")
|
|
or transport.transport_status == "cancelled"
|
|
)
|
|
archived = bool(plan is not None and plan.archive_status == "archived")
|
|
return voided, archived
|
|
|
|
|
|
def _resolve_other_personnel(transport: PlTransport, plan: Optional[PlPlan]) -> list[PublicTransportPersonnelRead]:
|
|
raw = transport.resource_snapshot or (plan.resource_snapshot if plan else None)
|
|
snapshot = parse_resource_snapshot(raw)
|
|
if not snapshot or not snapshot.personnel_list:
|
|
return []
|
|
|
|
selected_driver_id = (snapshot.selected_driver_id or transport.driver_id or "").strip()
|
|
driver_name = (transport.driver_name or "").strip()
|
|
rows: list[PublicTransportPersonnelRead] = []
|
|
for person in snapshot.personnel_list:
|
|
personnel_id = (person.personnel_id or "").strip()
|
|
personnel_name = (person.personnel_name or "").strip()
|
|
is_primary_driver = person.role == "driver" and (
|
|
person.is_default == 1
|
|
or (selected_driver_id and personnel_id == selected_driver_id)
|
|
or (driver_name and personnel_name == driver_name)
|
|
)
|
|
if is_primary_driver:
|
|
continue
|
|
rows.append(
|
|
PublicTransportPersonnelRead(
|
|
personnel_name=personnel_name or "—",
|
|
phone_no=person.phone_no,
|
|
role=person.role,
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportPageRead:
|
|
link = (
|
|
db.query(PlPlanTransport)
|
|
.filter(
|
|
PlPlanTransport.transport_id == transport.id,
|
|
PlPlanTransport.del_flag == 0,
|
|
)
|
|
.order_by(PlPlanTransport.create_time.desc())
|
|
.first()
|
|
)
|
|
plan = None
|
|
products: list[PlPlanProduct] = []
|
|
if link:
|
|
plan = (
|
|
db.query(PlPlan)
|
|
.filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
|
|
.first()
|
|
)
|
|
if plan:
|
|
products = (
|
|
db.query(PlPlanProduct)
|
|
.filter(PlPlanProduct.plan_id == plan.id, PlPlanProduct.del_flag == 0)
|
|
.order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
|
|
.all()
|
|
)
|
|
|
|
crm_product_ids = {(p.product_id or "").strip() for p in products if (p.product_id or "").strip()}
|
|
brand_options_by_product: dict[str, list[BrandOption]] = {}
|
|
if crm_product_ids:
|
|
brand_rows = (
|
|
db.query(CrmProductBrand)
|
|
.filter(
|
|
CrmProductBrand.product_id.in_(list(crm_product_ids)),
|
|
CrmProductBrand.tenant_no == transport.tenant_no,
|
|
CrmProductBrand.status == "on",
|
|
CrmProductBrand.del_flag == 0,
|
|
)
|
|
.order_by(CrmProductBrand.order_num.asc(), CrmProductBrand.create_time.asc())
|
|
.all()
|
|
)
|
|
for row in brand_rows:
|
|
brand_options_by_product.setdefault(row.product_id, []).append(
|
|
BrandOption(brand_id=row.id, brand_name=row.brand_name or "")
|
|
)
|
|
|
|
feedback_by_plan_product: dict[str, PlTransportProductFeedback] = {}
|
|
brand_qtys_by_plan_product: dict[str, list[BrandQtyItem]] = {}
|
|
feedback_rows = (
|
|
db.query(PlTransportProductFeedback)
|
|
.filter(
|
|
PlTransportProductFeedback.transport_id == transport.id,
|
|
PlTransportProductFeedback.tenant_no == transport.tenant_no,
|
|
PlTransportProductFeedback.del_flag == 0,
|
|
)
|
|
.order_by(PlTransportProductFeedback.order_num.asc())
|
|
.all()
|
|
)
|
|
for fb in feedback_rows:
|
|
feedback_by_plan_product[fb.plan_product_id] = fb
|
|
|
|
if feedback_rows:
|
|
feedback_ids = [fb.id for fb in feedback_rows]
|
|
brand_qty_rows = (
|
|
db.query(PlTransportProductBrandQty)
|
|
.filter(
|
|
PlTransportProductBrandQty.transport_id == transport.id,
|
|
PlTransportProductBrandQty.tenant_no == transport.tenant_no,
|
|
PlTransportProductBrandQty.del_flag == 0,
|
|
PlTransportProductBrandQty.feedback_id.in_(feedback_ids),
|
|
)
|
|
.order_by(PlTransportProductBrandQty.order_num.asc())
|
|
.all()
|
|
)
|
|
for row in brand_qty_rows:
|
|
brand_qtys_by_plan_product.setdefault(row.plan_product_id, []).append(
|
|
BrandQtyItem(
|
|
brand_id=row.brand_id or "",
|
|
brand_name=row.brand_name,
|
|
qty=row.qty or 0,
|
|
)
|
|
)
|
|
|
|
standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
|
|
db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
|
|
)
|
|
|
|
product_reads = []
|
|
unit_map = load_dict_label_maps(
|
|
db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
|
|
).get(CRM_PRODUCT_UNIT, {})
|
|
for prod in products:
|
|
product_id = (prod.product_id or "").strip()
|
|
options = brand_options_by_product.get(product_id) or None
|
|
fb = feedback_by_plan_product.get(prod.id)
|
|
brand_qtys = brand_qtys_by_plan_product.get(prod.id) or None
|
|
display_name, alias_name = resolve_product_display_names(
|
|
saved_product_name=prod.product_name,
|
|
product_id=product_id,
|
|
standard_by_id=standard_by_id,
|
|
alias_options_by_id=alias_options_by_id,
|
|
)
|
|
product_reads.append(
|
|
PublicTransportPlanProductRead(
|
|
plan_product_id=prod.id,
|
|
product_name=display_name,
|
|
alias_name=alias_name,
|
|
model=prod.model,
|
|
package_spec=prod.package_spec,
|
|
unit=_resolve_unit_label(unit_map, prod.unit),
|
|
quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
|
|
remark=(prod.remark or "").strip() or None,
|
|
brand_mode=prod.brand_mode,
|
|
brand_id=prod.brand_id,
|
|
brand_name=prod.brand_name,
|
|
brand_options=options,
|
|
actual_qty=fb.actual_qty if fb else None,
|
|
brand_qtys=brand_qtys,
|
|
)
|
|
)
|
|
|
|
other_personnel = _resolve_other_personnel(transport, plan)
|
|
plan_voided, plan_archived = _resolve_driver_link_flags(plan, transport)
|
|
|
|
stop_reads: list[PublicTransportStopRead] = []
|
|
if plan and plan_has_load_stop(db, transport.tenant_no, plan.id):
|
|
plan_products_by_id = {p.id: p for p in products}
|
|
stop_reads = [
|
|
PublicTransportStopRead(
|
|
stop_type=segment.stop_type,
|
|
stop_id=segment.stop_id,
|
|
seq=segment.seq,
|
|
label=segment.label,
|
|
customer_name=segment.customer_name,
|
|
address=segment.address,
|
|
contact=segment.contact,
|
|
notes=segment.notes,
|
|
lng=segment.lng,
|
|
lat=segment.lat,
|
|
business_type=segment.business_type,
|
|
plan_qty=segment.plan_qty,
|
|
products=_build_stop_product_reads(
|
|
stop_products=segment.products,
|
|
plan_products_by_id=plan_products_by_id,
|
|
unit_map=unit_map,
|
|
brand_options_by_product=brand_options_by_product,
|
|
standard_by_id=standard_by_id,
|
|
alias_options_by_id=alias_options_by_id,
|
|
),
|
|
attachment_urls=segment.attachment_urls,
|
|
remark=segment.remark,
|
|
feedback_time=segment.feedback_time,
|
|
)
|
|
for segment in load_driver_stop_segments(
|
|
db,
|
|
tenant_no=transport.tenant_no,
|
|
plan_id=plan.id,
|
|
transport_id=transport.id,
|
|
)
|
|
]
|
|
|
|
return PublicTransportPageRead(
|
|
plan_no=plan.plan_no if plan else transport.transport_no,
|
|
plan_date=_format_plan_date(plan.plan_date if plan else None),
|
|
plan_type=plan.plan_type if plan else "",
|
|
pickup_address=plan.pickup_address if plan else transport.start_point,
|
|
pickup_lng=plan.pickup_lng if plan else None,
|
|
pickup_lat=plan.pickup_lat if plan else None,
|
|
pickup_time=plan.pickup_time if plan else None,
|
|
pickup_contact=plan.pickup_contact if plan else None,
|
|
pickup_notes=plan.pickup_notes if plan else None,
|
|
destination_name=plan.destination_name if plan else None,
|
|
destination_address=(
|
|
(plan.destination_address or plan.destination_name) if plan else transport.end_point
|
|
),
|
|
destination_lng=plan.destination_lng if plan else None,
|
|
destination_lat=plan.destination_lat if plan else None,
|
|
delivery_time=plan.delivery_time if plan else None,
|
|
delivery_contact=plan.delivery_contact if plan else None,
|
|
destination_notes=plan.destination_notes if plan else None,
|
|
license_plate=transport.license_plate,
|
|
driver_name=transport.driver_name,
|
|
driver_phone=transport.driver_phone,
|
|
other_personnel=other_personnel,
|
|
products=product_reads,
|
|
stops=stop_reads,
|
|
feedback_remark=transport.feedback_remark,
|
|
feedback_images=_parse_feedback_images(transport.feedback_images),
|
|
feedback_qty=transport.feedback_qty,
|
|
feedback_time=transport.feedback_time,
|
|
plan_voided=plan_voided,
|
|
plan_archived=plan_archived,
|
|
)
|
|
|
|
|
|
@router.get("/{token}")
|
|
def get_public_transport(token: str, db: Session = Depends(get_db)):
|
|
transport = _get_transport_by_token(db, token)
|
|
page = _build_public_page(db, transport)
|
|
return success_response(page.model_dump(by_alias=True))
|
|
|
|
|
|
@router.get("/{token}/history")
|
|
def get_public_transport_history(
|
|
token: str,
|
|
date: str = Query(..., description="筛选日 YYYY-MM-DD"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
transport = _get_transport_by_token(db, token)
|
|
day = _parse_history_date(date)
|
|
if day is None:
|
|
return error_response(400, "日期格式无效,请使用 YYYY-MM-DD")
|
|
|
|
plate = _normalize_main_plate(transport.license_plate)
|
|
if not plate:
|
|
page = PublicTransportHistoryPageRead(
|
|
license_plate=None, date=day.isoformat(), records=[]
|
|
)
|
|
return success_response(page.model_dump(by_alias=True))
|
|
|
|
rows = (
|
|
db.query(PlPlan, PlTransport)
|
|
.join(
|
|
PlPlanTransport,
|
|
and_(
|
|
PlPlanTransport.plan_id == PlPlan.id,
|
|
PlPlanTransport.del_flag == 0,
|
|
),
|
|
)
|
|
.join(
|
|
PlTransport,
|
|
and_(
|
|
PlTransport.id == PlPlanTransport.transport_id,
|
|
PlTransport.del_flag == 0,
|
|
),
|
|
)
|
|
.filter(
|
|
PlPlan.tenant_no == transport.tenant_no,
|
|
PlPlan.del_flag == 0,
|
|
PlPlan.transport_status == "arranged",
|
|
PlTransport.tenant_no == transport.tenant_no,
|
|
PlTransport.license_plate == plate,
|
|
or_(
|
|
and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
|
|
and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
|
|
),
|
|
)
|
|
.order_by(
|
|
PlPlan.pickup_time.desc(),
|
|
PlPlan.plan_date.desc(),
|
|
PlPlan.create_time.desc(),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
seen_plan: set[str] = set()
|
|
plan_rows: list[tuple[PlPlan, PlTransport]] = []
|
|
for plan, tr in rows:
|
|
if plan.id in seen_plan:
|
|
continue
|
|
seen_plan.add(plan.id)
|
|
plan_rows.append((plan, tr))
|
|
|
|
products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
|
|
if plan_rows:
|
|
plan_ids = [plan.id for plan, _ in plan_rows]
|
|
product_rows = (
|
|
db.query(PlPlanProduct)
|
|
.filter(
|
|
PlPlanProduct.plan_id.in_(plan_ids),
|
|
PlPlanProduct.del_flag == 0,
|
|
)
|
|
.order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
|
|
.all()
|
|
)
|
|
crm_product_ids = {
|
|
(p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
|
|
}
|
|
standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
|
|
db, tenant_no=transport.tenant_no, product_ids=crm_product_ids
|
|
)
|
|
unit_map = load_dict_label_maps(
|
|
db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no
|
|
).get(CRM_PRODUCT_UNIT, {})
|
|
for prod in product_rows:
|
|
product_id = (prod.product_id or "").strip()
|
|
display_name, _alias_name = resolve_product_display_names(
|
|
saved_product_name=prod.product_name,
|
|
product_id=product_id,
|
|
standard_by_id=standard_by_id,
|
|
alias_options_by_id=alias_options_by_id,
|
|
)
|
|
products_by_plan.setdefault(prod.plan_id, []).append(
|
|
PublicTransportHistoryProductRead(
|
|
product_name=display_name or prod.product_name or "—",
|
|
model=prod.model,
|
|
package_spec=prod.package_spec,
|
|
quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
|
|
unit=_resolve_unit_label(unit_map, prod.unit),
|
|
)
|
|
)
|
|
|
|
records: list[PublicTransportHistoryRecordRead] = []
|
|
for plan, tr in plan_rows:
|
|
voided, archived = _resolve_driver_link_flags(plan, tr)
|
|
tok = (tr.driver_access_token or "").strip()
|
|
records.append(
|
|
PublicTransportHistoryRecordRead(
|
|
plan_id=plan.id,
|
|
plan_no=plan.plan_no or "",
|
|
transport_id=tr.id,
|
|
driver_access_token=tok or None,
|
|
driver_url=_build_driver_url(tok) if tok else None,
|
|
license_plate=tr.license_plate or plate,
|
|
pickup_time=plan.pickup_time,
|
|
delivery_time=plan.delivery_time,
|
|
pickup_address=plan.pickup_address,
|
|
destination_address=plan.destination_address or plan.destination_name,
|
|
products=products_by_plan.get(plan.id) or [],
|
|
stops=_build_history_stop_reads(
|
|
db, tenant_no=transport.tenant_no, plan=plan, transport=tr
|
|
),
|
|
plan_voided=voided,
|
|
plan_archived=archived,
|
|
status_label=_history_status_label(voided, archived),
|
|
)
|
|
)
|
|
|
|
page = PublicTransportHistoryPageRead(
|
|
license_plate=plate,
|
|
date=day.isoformat(),
|
|
records=records,
|
|
)
|
|
return success_response(page.model_dump(by_alias=True))
|
|
|
|
|
|
@router.post("/{token}/feedback")
|
|
def save_public_feedback(
|
|
token: str,
|
|
payload: PublicTransportFeedback,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
transport = _get_transport_by_token(db, token)
|
|
page = _build_public_page(db, transport)
|
|
if page.plan_voided:
|
|
return error_response(400, "运输计划已作废,无法提交回执")
|
|
if page.plan_archived:
|
|
return error_response(400, "运输计划已归档,无法提交回执")
|
|
|
|
if payload.feedback_images is not None:
|
|
transport.feedback_images = json.dumps(payload.feedback_images)
|
|
|
|
if payload.feedback_remark is not None:
|
|
transport.feedback_remark = payload.feedback_remark
|
|
|
|
if payload.products:
|
|
link = (
|
|
db.query(PlPlanTransport)
|
|
.filter(
|
|
PlPlanTransport.transport_id == transport.id,
|
|
PlPlanTransport.del_flag == 0,
|
|
)
|
|
.order_by(PlPlanTransport.create_time.desc())
|
|
.first()
|
|
)
|
|
if not link or not link.plan_id:
|
|
return error_response(400, "未找到关联计划,无法保存产品回传")
|
|
products_payload = [
|
|
{
|
|
"planProductId": item.plan_product_id,
|
|
"actualQty": item.actual_qty,
|
|
"brandQtys": [b.model_dump(by_alias=True) for b in (item.brand_qtys or [])],
|
|
}
|
|
for item in payload.products
|
|
]
|
|
try:
|
|
persist_transport_product_feedback(
|
|
db,
|
|
tenant_no=transport.tenant_no,
|
|
transport=transport,
|
|
plan_id=link.plan_id,
|
|
products=products_payload,
|
|
user_id=None,
|
|
enforce_brand=True,
|
|
)
|
|
except ValueError as exc:
|
|
return error_response(400, str(exc))
|
|
elif payload.feedback_qty is not None:
|
|
transport.feedback_qty = payload.feedback_qty
|
|
|
|
transport.feedback_time = datetime.now()
|
|
db.commit()
|
|
return success_response({"success": True, "message": "保存成功"})
|
|
|
|
|
|
@router.post("/{token}/driver-feedback")
|
|
def save_public_driver_stop_feedback(
|
|
token: str,
|
|
payload: PublicTransportDriverStopFeedback,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
transport = _get_transport_by_token(db, token)
|
|
page = _build_public_page(db, transport)
|
|
if page.plan_voided:
|
|
return error_response(400, "运输计划已作废,无法提交回执")
|
|
if page.plan_archived:
|
|
return error_response(400, "运输计划已归档,无法提交回执")
|
|
|
|
link = (
|
|
db.query(PlPlanTransport)
|
|
.filter(
|
|
PlPlanTransport.transport_id == transport.id,
|
|
PlPlanTransport.del_flag == 0,
|
|
)
|
|
.order_by(PlPlanTransport.create_time.desc())
|
|
.first()
|
|
)
|
|
if not link or not link.plan_id:
|
|
return error_response(400, "未找到关联计划,无法保存段附件")
|
|
|
|
stop_type = (payload.stop_type or "").strip()
|
|
stop_id = (payload.stop_id or "").strip()
|
|
if stop_type not in ("load", "delivery") or not stop_id:
|
|
return error_response(400, "停靠段参数无效")
|
|
|
|
allowed = {
|
|
(segment.stop_type, segment.stop_id)
|
|
for segment in load_driver_stop_segments(
|
|
db,
|
|
tenant_no=transport.tenant_no,
|
|
plan_id=link.plan_id,
|
|
transport_id=transport.id,
|
|
)
|
|
}
|
|
if (stop_type, stop_id) not in allowed:
|
|
return error_response(400, "停靠段不存在或不可回传")
|
|
|
|
segment = next(
|
|
(
|
|
row
|
|
for row in load_driver_stop_segments(
|
|
db,
|
|
tenant_no=transport.tenant_no,
|
|
plan_id=link.plan_id,
|
|
transport_id=transport.id,
|
|
)
|
|
if row.stop_type == stop_type and row.stop_id == stop_id
|
|
),
|
|
None,
|
|
)
|
|
if not segment:
|
|
return error_response(400, "停靠段不存在或不可回传")
|
|
|
|
products_payload: Optional[list[dict]] = None
|
|
if payload.products is not None:
|
|
allowed_products = {row.stop_product_id: row for row in segment.products}
|
|
plan_products = (
|
|
db.query(PlPlanProduct)
|
|
.filter(
|
|
PlPlanProduct.plan_id == link.plan_id,
|
|
PlPlanProduct.tenant_no == transport.tenant_no,
|
|
PlPlanProduct.del_flag == 0,
|
|
)
|
|
.all()
|
|
)
|
|
plan_products_by_id = {row.id: row for row in plan_products}
|
|
products_payload = []
|
|
for item in payload.products:
|
|
stop_product_id = (item.stop_product_id or "").strip()
|
|
stop_row = allowed_products.get(stop_product_id)
|
|
if not stop_row:
|
|
return error_response(400, "产品不属于当前停靠段")
|
|
actual_qty = Decimal(str(item.actual_qty or 0))
|
|
brand_qtys = [
|
|
b.model_dump(by_alias=True) for b in (item.brand_qtys or [])
|
|
]
|
|
plan_product = plan_products_by_id.get(stop_row.plan_product_id)
|
|
try:
|
|
require_brand_qtys_if_needed(
|
|
brand_mode=plan_product.brand_mode if plan_product else None,
|
|
required_brand_id=plan_product.brand_id if plan_product else None,
|
|
actual_qty=actual_qty,
|
|
rows=brand_qtys,
|
|
enforce=True,
|
|
)
|
|
except ValueError as exc:
|
|
return error_response(400, str(exc))
|
|
row_payload: dict = {
|
|
"stopProductId": stop_product_id,
|
|
"actualQty": float(actual_qty),
|
|
}
|
|
if brand_qtys:
|
|
row_payload["brandQtys"] = brand_qtys
|
|
products_payload.append(row_payload)
|
|
|
|
save_driver_stop_feedback(
|
|
db,
|
|
tenant_no=transport.tenant_no,
|
|
transport_id=transport.id,
|
|
plan_id=link.plan_id,
|
|
stop_type=stop_type,
|
|
stop_id=stop_id,
|
|
attachment_urls=payload.attachment_urls or [],
|
|
products=products_payload,
|
|
remark=payload.remark,
|
|
)
|
|
db.commit()
|
|
return success_response({"success": True, "message": "保存成功"})
|
|
|
|
|
|
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"
|
|
|
|
|
|
@router.post("/{token}/attachments")
|
|
def upload_public_attachment(
|
|
token: str,
|
|
file: UploadFile = File(...),
|
|
folder: Optional[str] = Form(""),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
transport = _get_transport_by_token(db, token)
|
|
page = _build_public_page(db, transport)
|
|
if page.plan_voided:
|
|
return error_response(400, "运输计划已作废,无法上传附件")
|
|
if page.plan_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_transport_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=transport.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_transport_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)}")
|