Files
shanxia2.0/backend/app/services/pl_driver_plan_service.py
T
wuzilongandCursor 1303fae1e0 feat(pl): add driver-plan service for personnel-scoped history
Resolve plans by linked driver/escort personnel and reuse public
driver page/feedback helpers for login-state miniprogram APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 11:44:24 +08:00

501 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运输计划 · 司机/押运登录态(history / page / feedback / upload)"""
from __future__ import annotations
import json
import os
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple
from fastapi import UploadFile
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.attachment_biz import resolve_upload_folder
from app.core.config import settings
from app.core.storage import storage
from app.crud import oss as oss_svc
from app.models.pl_models import (
PlPersonnel,
PlPlan,
PlPlanProduct,
PlPlanTransport,
PlTransport,
)
from app.schemas.pl.transport import (
PublicTransportDriverStopFeedback,
PublicTransportFeedback,
PublicTransportHistoryPageRead,
PublicTransportHistoryProductRead,
PublicTransportHistoryRecordRead,
PublicTransportPageRead,
)
from app.services.pl_feedback_brand_qty import (
persist_transport_product_feedback,
require_brand_qtys_if_needed,
)
from app.services.pl_product_display import (
load_crm_product_alias_maps,
resolve_product_display_names,
)
from app.services.pl_public_transport_helpers import (
CRM_PRODUCT_UNIT,
_history_status_label,
_parse_history_date,
_resolve_driver_link_flags,
_resolve_unit_label,
)
from app.services.pl_transport_stop_feedback_service import (
load_driver_stop_segments,
save_driver_stop_feedback,
)
from app.utils.excel_export import load_dict_label_maps
def transport_visible_to_personnel(transport: PlTransport, personnel_id: str) -> bool:
pid = (personnel_id or "").strip()
if not pid:
return False
return (transport.driver_id or "").strip() == pid or (transport.escort_id or "").strip() == pid
def resolve_personnel_for_user(
db: Session, *, tenant_no: str, user_id: str
) -> Optional[PlPersonnel]:
uid = (user_id or "").strip()
if not uid:
return None
return (
db.query(PlPersonnel)
.filter(
PlPersonnel.tenant_no == tenant_no,
PlPersonnel.sys_user_id == uid,
PlPersonnel.del_flag == 0,
)
.first()
)
def resolve_transport_for_driver_plan(
db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
) -> Tuple[Optional[PlPlan], Optional[PlTransport]]:
"""本租户计划 + 运输,且当前人员是司机或押运。"""
plan = (
db.query(PlPlan)
.filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no, PlPlan.del_flag == 0)
.first()
)
if not plan:
return None, None
link = (
db.query(PlPlanTransport)
.filter(PlPlanTransport.plan_id == plan.id, PlPlanTransport.del_flag == 0)
.order_by(PlPlanTransport.create_time.desc())
.first()
)
if not link or not link.transport_id:
return plan, None
transport = (
db.query(PlTransport)
.filter(
PlTransport.id == link.transport_id,
PlTransport.tenant_no == tenant_no,
PlTransport.del_flag == 0,
)
.first()
)
if not transport or not transport_visible_to_personnel(transport, personnel_id):
return plan, None
return plan, transport
def _build_driver_url(token: str) -> str:
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
return f"{origin}/#/public/pl/transport/{token}"
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 build_driver_history_page(
db: Session,
*,
tenant_no: str,
user_id: str,
date_str: str,
) -> Tuple[Optional[PublicTransportHistoryPageRead], Optional[str]]:
day = _parse_history_date(date_str)
if day is None:
return None, "日期格式无效,请使用 YYYY-MM-DD"
personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
if not personnel:
return (
PublicTransportHistoryPageRead(
license_plate=None, date=day.isoformat(), records=[]
),
None,
)
personnel_id = personnel.id
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 == tenant_no,
PlPlan.del_flag == 0,
PlPlan.transport_status == "arranged",
PlTransport.tenant_no == tenant_no,
or_(
PlTransport.driver_id == personnel_id,
PlTransport.escort_id == personnel_id,
),
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))
# Lazy import to reuse public history stop builder without circular import at module load.
from app.api.pl.public_transport import _build_history_stop_reads
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=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 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,
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=tenant_no, plan=plan, transport=tr
),
plan_voided=voided,
plan_archived=archived,
status_label=_history_status_label(voided, archived),
)
)
return (
PublicTransportHistoryPageRead(
license_plate=None,
date=day.isoformat(),
records=records,
),
None,
)
def build_driver_page_for_plan(
db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
) -> Tuple[Optional[PublicTransportPageRead], Optional[str]]:
plan, transport = resolve_transport_for_driver_plan(
db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
)
if not plan:
return None, "计划不存在"
if not transport:
return None, "无权限查看该运输计划或未安排运输"
from app.api.pl.public_transport import _build_public_page
return _build_public_page(db, transport), None
def save_driver_feedback_for_plan(
db: Session,
*,
tenant_no: str,
plan_id: str,
personnel_id: str,
payload: PublicTransportFeedback,
) -> Tuple[bool, Optional[str]]:
plan, transport = resolve_transport_for_driver_plan(
db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
)
if not plan:
return False, "计划不存在"
if not transport:
return False, "无权限操作该运输计划"
from app.api.pl.public_transport import _build_public_page
page = _build_public_page(db, transport)
if page.plan_voided:
return False, "运输计划已作废,无法提交回执"
if page.plan_archived:
return False, "运输计划已归档,无法提交回执"
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:
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=plan.id,
products=products_payload,
user_id=None,
enforce_brand=True,
)
except ValueError as exc:
return False, str(exc)
elif payload.feedback_qty is not None:
transport.feedback_qty = payload.feedback_qty
transport.feedback_time = datetime.now()
return True, None
def save_driver_stop_feedback_for_plan(
db: Session,
*,
tenant_no: str,
plan_id: str,
personnel_id: str,
payload: PublicTransportDriverStopFeedback,
) -> Tuple[bool, Optional[str]]:
plan, transport = resolve_transport_for_driver_plan(
db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
)
if not plan:
return False, "计划不存在"
if not transport:
return False, "无权限操作该运输计划"
from app.api.pl.public_transport import _build_public_page
page = _build_public_page(db, transport)
if page.plan_voided:
return False, "运输计划已作废,无法提交回执"
if page.plan_archived:
return False, "运输计划已归档,无法提交回执"
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 False, "停靠段参数无效"
segments = load_driver_stop_segments(
db,
tenant_no=transport.tenant_no,
plan_id=plan.id,
transport_id=transport.id,
)
segment = next(
(row for row in segments if row.stop_type == stop_type and row.stop_id == stop_id),
None,
)
if not segment:
return False, "停靠段不存在或不可回传"
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 == 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 False, "产品不属于当前停靠段"
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 False, 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=plan.id,
stop_type=stop_type,
stop_id=stop_id,
attachment_urls=payload.attachment_urls or [],
products=products_payload,
remark=payload.remark,
)
return True, None
def upload_driver_attachment_for_plan(
db: Session,
*,
tenant_no: str,
plan_id: str,
personnel_id: str,
file: UploadFile,
folder: str = "",
) -> Tuple[Optional[dict[str, Any]], Optional[Tuple[int, str]]]:
plan, transport = resolve_transport_for_driver_plan(
db, tenant_no=tenant_no, plan_id=plan_id, personnel_id=personnel_id
)
if not plan:
return None, (404, "计划不存在")
if not transport:
return None, (403, "无权限操作该运输计划")
from app.api.pl.public_transport import _build_public_page
page = _build_public_page(db, transport)
if page.plan_voided:
return None, (400, "运输计划已作废,无法上传附件")
if page.plan_archived:
return None, (400, "运输计划已归档,无法上传附件")
if not file or not file.filename:
return None, (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 {
"url": full_url,
"name": file.filename,
"size": _format_file_size(len(file_content)),
"ossId": record.id if record else None,
}, None
except Exception as exc: # noqa: BLE001
db.rollback()
return None, (500, f"上传失败: {exc}")