| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640 |
- """运输计划 · 司机/押运登录态(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,
- _accumulate_calendar_day_counts,
- _history_status_label,
- _parse_calendar_month,
- _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 allocate_or_get_plan_copy_seq(
- db: Session, *, tenant_no: str, plan_id: str
- ) -> Optional[int]:
- """
- 返回计划的 copy_seq_no;若为空则按租户 MAX(含软删)+1 写入后返回。
- 调用方负责 commit。计划不存在返回 None。
- """
- plan = (
- db.query(PlPlan)
- .filter(PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no)
- .with_for_update()
- .first()
- )
- if not plan:
- return None
- if plan.copy_seq_no is not None:
- return int(plan.copy_seq_no)
- max_seq = (
- db.query(func.max(PlPlan.copy_seq_no))
- .filter(PlPlan.tenant_no == tenant_no)
- .with_for_update()
- .scalar()
- )
- next_seq = int(max_seq or 0) + 1
- plan.copy_seq_no = next_seq
- db.flush()
- return next_seq
- def ensure_copy_seq_for_driver(
- db: Session, *, tenant_no: str, plan_id: str, personnel_id: str
- ) -> Tuple[Optional[int], Optional[str]]:
- """
- 司机可见性与 GET 详情一致;通过后分配/返回 copy_seq_no。
- 成功返回 (seq, None);失败返回 (None, err)。
- """
- 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, "无权限查看该运输计划或未安排运输"
- seq = allocate_or_get_plan_copy_seq(db, tenant_no=tenant_no, plan_id=plan.id)
- if seq is None:
- return None, "计划不存在"
- return seq, None
- 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),
- )
- )
- from app.services.db.pound_qr_public import build_pound_qrcode_items_for_plan
- records: list[PublicTransportHistoryRecordRead] = []
- for plan, tr in plan_rows:
- voided, archived = _resolve_driver_link_flags(plan, tr)
- tok = (tr.driver_access_token or "").strip()
- pound_items = build_pound_qrcode_items_for_plan(
- db,
- tenant_no=tenant_no,
- plan_id=plan.id,
- transport_id=tr.id,
- )
- 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),
- pound_qrcode_items=pound_items,
- )
- )
- return (
- PublicTransportHistoryPageRead(
- license_plate=None,
- date=day.isoformat(),
- records=records,
- ),
- None,
- )
- def build_driver_calendar_counts(
- db: Session,
- *,
- tenant_no: str,
- user_id: str,
- month_str: str,
- ) -> Tuple[Optional[dict[str, int]], Optional[str]]:
- """按月统计司机可见计划条数,键为 YYYY-MM-DD。"""
- bounds = _parse_calendar_month(month_str)
- if bounds is None:
- return None, "月份格式无效,请使用 YYYY-MM"
- month_start, month_end = bounds
- personnel = resolve_personnel_for_user(db, tenant_no=tenant_no, user_id=user_id)
- if not personnel:
- return {}, None
- personnel_id = personnel.id
- rows = (
- db.query(PlPlan)
- .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) >= month_start,
- func.date(PlPlan.pickup_time) <= month_end,
- ),
- and_(
- PlPlan.delivery_time.isnot(None),
- func.date(PlPlan.delivery_time) >= month_start,
- func.date(PlPlan.delivery_time) <= month_end,
- ),
- ),
- )
- .all()
- )
- seen: set[str] = set()
- plans: list[PlPlan] = []
- for plan in rows:
- if plan.id in seen:
- continue
- seen.add(plan.id)
- plans.append(plan)
- return (
- _accumulate_calendar_day_counts(
- plans,
- month_start=month_start,
- month_end=month_end,
- include_plan_date=False,
- ),
- 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] if item.brand_qtys is not None else None),
- }
- 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]
- if item.brand_qtys is not None else (stop_row.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 item.brand_qtys is not None:
- 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}")
|