Files
shanxia2.0/backend/app/api/pl/public_transport.py
T
2026-08-22 09:08:05 +08:00

589 lines
21 KiB
Python

"""运输计划司机免登录公共 API"""
from __future__ import annotations
import json
import os
from datetime import date, datetime
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,
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 (
PublicTransportFeedback,
PublicTransportHistoryPageRead,
PublicTransportHistoryProductRead,
PublicTransportHistoryRecordRead,
PublicTransportPageRead,
PublicTransportPersonnelRead,
PublicTransportPlanProductRead,
)
from app.services.pl_feedback_brand_qty import persist_transport_product_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 _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_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)
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,
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 [],
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": "保存成功"})
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)}")