413 lines
15 KiB
Python
413 lines
15 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, UploadFile
|
|
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,
|
|
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 _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.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)}")
|