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>
This commit is contained in:
1 parent
ca3062b0e2
commit
1303fae1e0
2 files changed
+530
No files matched your search
@@ -0,0 +1,500 @@
|
||||
"""运输计划 · 司机/押运登录态(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}")
|
||||
@@ -0,0 +1,30 @@
|
||||
# backend/tests/test_pl_driver_plan_service.py
|
||||
from datetime import date, datetime
|
||||
|
||||
from app.api.pl.public_transport import _history_matches_date
|
||||
from app.services.pl_driver_plan_service import transport_visible_to_personnel
|
||||
|
||||
|
||||
def test_history_date_or_logic():
|
||||
day = date(2026, 9, 13)
|
||||
assert _history_matches_date(datetime(2026, 9, 13, 9, 0), None, day) is True
|
||||
assert _history_matches_date(None, datetime(2026, 9, 13, 18, 0), day) is True
|
||||
assert (
|
||||
_history_matches_date(
|
||||
datetime(2026, 9, 12, 9, 0), datetime(2026, 9, 14, 18, 0), day
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_visible_as_driver_or_escort():
|
||||
class T:
|
||||
def __init__(self, driver_id=None, escort_id=None):
|
||||
self.driver_id = driver_id
|
||||
self.escort_id = escort_id
|
||||
|
||||
assert transport_visible_to_personnel(T(driver_id="p1"), "p1") is True
|
||||
assert transport_visible_to_personnel(T(escort_id="p1"), "p1") is True
|
||||
assert transport_visible_to_personnel(T(driver_id="p2", escort_id="p3"), "p1") is False
|
||||
assert transport_visible_to_personnel(T(), "p1") is False
|
||||
assert transport_visible_to_personnel(T(driver_id="p1"), "") is False
|
||||
Reference in new issue
Block a user