"""运输计划管理 - 运输 API""" from datetime import date, datetime from typing import List, Optional import json import secrets from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.core.database import get_db from app.core.db_auth import DbUserContext, get_db_current_user from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope from app.core.tenant_context import get_tenant_no from app.utils.excel_export import ( EXPORT_MAX_ROWS, ExportColumn, ExportValidationError, export_rows_to_xlsx_response, filter_export_columns, ) from app.core.utils import generate_uuid from app.core.config import settings from app.models.pl_models import ( PlCarrier, PlDriver, PlPersonnel, PlPlan, PlPlanProduct, PlPlanTransport, PlTransport, PlTransportApproval, PlTransportDbLink, PlVehicle, ) from app.schemas.pl.io_order import BrandQtyItem from app.schemas.pl.transport import ( ApprovalRecordRead, ApprovalRequest, ApprovalSubmit, TransportArrange, TransportArrangeResult, TransportCreate, TransportFeedback, TransportInventoryLinkRead, TransportIoOrderBrief, TransportSiteLinkRead, TransportListQuery, TransportListResponse, TransportDriverProductFeedbackRead, TransportDriverStopProductFeedbackRead, TransportRead, TransportStopFeedbackRead, ) from app.services.pl_dibang_bridge_service import ( StopDibangOption, plan_has_load_stop, revoke_regeneratable_dibang_for_transport, sync_dibang_for_plan_stops, sync_dibang_for_transport, ) from app.services.pl_transport_stop_feedback_service import ( build_transport_site_url, ensure_transport_site_token, load_driver_stop_segments, load_site_stop_segments, rotate_transport_site_token, ) from app.services.pl_inventory_link_read import build_transport_inventory_link_read from app.services.pl_io_order_service import ( apply_dibang_prefill_to_order, create_io_order_for_transport, void_io_orders_for_plan, ) from app.services.pl_internal_customer import plan_requires_site_link from app.services.pl_plan_service import ( validate_hazard_cert_ready_for_transport, ) from app.services.pl_transport_resource_snapshot_service import ( apply_resource_snapshot_json, build_transport_resource_snapshot, ) from app.services.pl_carrier_service import assert_carrier_eligible_for_transport from app.services.pl_feedback_brand_qty import load_transport_driver_product_feedbacks router = APIRouter(prefix="/order", tags=["运输计划-运输管理"]) def _transport_to_read(db: Session, transport: PlTransport) -> TransportRead: base = TransportRead.model_validate(transport) feedbacks = load_transport_driver_product_feedbacks( db, tenant_no=transport.tenant_no, transport_id=transport.id, ) updates: dict = {} if feedbacks: updates["driver_product_feedbacks"] = [ TransportDriverProductFeedbackRead.model_validate(item) for item in feedbacks ] link = ( db.query(PlPlanTransport) .filter( PlPlanTransport.transport_id == transport.id, PlPlanTransport.del_flag == 0, ) .order_by(PlPlanTransport.create_time.desc()) .first() ) if link and link.plan_id and plan_has_load_stop(db, transport.tenant_no, link.plan_id): updates["driver_stop_feedbacks"] = [ TransportStopFeedbackRead( stop_type=segment.stop_type, stop_id=segment.stop_id, label=segment.label, customer_name=segment.customer_name, attachment_urls=segment.attachment_urls, products=[ TransportDriverStopProductFeedbackRead( stop_product_id=product.stop_product_id, plan_product_id=product.plan_product_id, product_name=product.product_name, model=product.model, unit=product.unit, package_spec=product.package_spec, quantity=product.plan_qty, actual_qty=product.actual_qty, brand_qtys=[ BrandQtyItem( brand_id=str(item.get("brandId") or item.get("brand_id") or ""), brand_name=item.get("brandName") or item.get("brand_name"), qty=item.get("qty") or 0, ) for item in (product.brand_qtys or []) if isinstance(item, dict) ] or None, ) for product in segment.products if product.actual_qty is not None or (product.brand_qtys and len(product.brand_qtys) > 0) ], remark=segment.remark, feedback_time=segment.feedback_time, ) for segment in load_driver_stop_segments( db, tenant_no=transport.tenant_no, plan_id=link.plan_id, transport_id=transport.id, ) if segment.attachment_urls or segment.remark or segment.feedback_time or any( product.actual_qty is not None or (product.brand_qtys and len(product.brand_qtys) > 0) for product in segment.products ) ] updates["site_stop_feedbacks"] = [ TransportStopFeedbackRead( stop_type=segment.stop_type, stop_id=segment.stop_id, label=segment.label, customer_name=segment.customer_name, business_type=segment.business_type, quantity=segment.quantity, attachment_urls=segment.attachment_urls, products=[ TransportDriverStopProductFeedbackRead( stop_product_id=product.stop_product_id, plan_product_id=product.plan_product_id, product_name=product.product_name, model=product.model, unit=product.unit, package_spec=product.package_spec, quantity=product.plan_qty, actual_qty=product.actual_qty, brand_qtys=[ BrandQtyItem( brand_id=str(item.get("brandId") or item.get("brand_id") or ""), brand_name=item.get("brandName") or item.get("brand_name"), qty=item.get("qty") or 0, ) for item in (product.brand_qtys or []) if isinstance(item, dict) ] or None, ) for product in segment.products if product.actual_qty is not None or (product.brand_qtys and len(product.brand_qtys) > 0) ], remark=segment.remark, feedback_time=segment.feedback_time, ) for segment in load_site_stop_segments( db, tenant_no=transport.tenant_no, plan_id=link.plan_id, transport_id=transport.id, ) if segment.quantity is not None or segment.attachment_urls or segment.remark or any( product.actual_qty is not None or (product.brand_qtys and len(product.brand_qtys) > 0) for product in segment.products ) ] if not updates: return base return base.model_copy(update=updates) def _generate_transport_no(db: Session) -> str: """生成运输编号 SXYS######""" now = date.today() prefix = f"SXYS{now.strftime('%Y%m')}" last = ( db.query(PlTransport) .filter( PlTransport.transport_no.like(f"{prefix}%"), PlTransport.del_flag == 0, ) .order_by(PlTransport.transport_no.desc()) .first() ) if last: last_seq = int(last.transport_no[-6:]) seq = last_seq + 1 else: seq = 1 return f"{prefix}{seq:06d}" def _resolve_driver_info(db: Session, tenant_no: str, driver_id: Optional[str]) -> Optional[dict]: """解析司机信息:优先 pl_personnel,兼容旧 pl_driver。""" if not driver_id: return None personnel = ( db.query(PlPersonnel) .filter( PlPersonnel.id == driver_id, PlPersonnel.tenant_no == tenant_no, PlPersonnel.del_flag == 0, ) .first() ) if personnel: return { "id": personnel.id, "name": personnel.personnel_name, "phone": personnel.phone_no, } legacy = ( db.query(PlDriver) .filter( PlDriver.id == driver_id, PlDriver.tenant_no == tenant_no, PlDriver.del_flag == 0, ) .first() ) if legacy: return { "id": legacy.id, "name": legacy.driver_name, "phone": legacy.phone_no, } return None def _get_vehicle_info(db: Session, vehicle_id: str, tenant_no: Optional[str] = None) -> dict: vehicle = db.query(PlVehicle).filter(PlVehicle.id == vehicle_id).first() if not vehicle: return {} info = { "license_plate": vehicle.license_plate, "carrier_id": vehicle.carrier_id, "carrier_name": vehicle.carrier_name, } if vehicle.default_driver_id: driver_info = ( _resolve_driver_info(db, tenant_no, vehicle.default_driver_id) if tenant_no else None ) if not driver_info: personnel = db.query(PlPersonnel).filter( PlPersonnel.id == vehicle.default_driver_id, PlPersonnel.del_flag == 0, ).first() if personnel: driver_info = { "id": personnel.id, "name": personnel.personnel_name, "phone": personnel.phone_no, } else: legacy = db.query(PlDriver).filter( PlDriver.id == vehicle.default_driver_id, PlDriver.del_flag == 0, ).first() if legacy: driver_info = { "id": legacy.id, "name": legacy.driver_name, "phone": legacy.phone_no, } if driver_info: info["driver_id"] = driver_info["id"] info["driver_name"] = driver_info["name"] info["driver_phone"] = driver_info["phone"] elif vehicle.default_driver_name: info["driver_id"] = vehicle.default_driver_id info["driver_name"] = vehicle.default_driver_name return info def _assert_vehicle_carrier_eligible( db: Session, tenant_no: str, vehicle_id: str, *, rearrange: bool = False, ) -> None: vehicle = ( db.query(PlVehicle) .filter( PlVehicle.id == vehicle_id, PlVehicle.tenant_no == tenant_no, PlVehicle.del_flag == 0, ) .first() ) if not vehicle: raise HTTPException(status_code=400, detail="车辆不存在") if not vehicle.carrier_id: return carrier = ( db.query(PlCarrier) .filter( PlCarrier.id == vehicle.carrier_id, PlCarrier.tenant_no == tenant_no, PlCarrier.del_flag == 0, ) .first() ) if not carrier: raise HTTPException(status_code=400, detail="承运商不存在") assert_carrier_eligible_for_transport(carrier, rearrange=rearrange) def _ensure_driver_access_token(transport: PlTransport) -> str: token = (transport.driver_access_token or "").strip() if not token: token = secrets.token_urlsafe(32)[:64] transport.driver_access_token = token return token def _rotate_driver_access_token(transport: PlTransport) -> str: token = secrets.token_urlsafe(32)[:64] transport.driver_access_token = token return token def _is_rearrange(plan: PlPlan, existing_transport: Optional[PlTransport]) -> bool: """仅计划已正式安排才算「调整运输」。 创建/编辑表单预选车辆会留下 draft 运输单,计划仍为 pending; 若把非 cancelled 运输单都当成 rearrange,会把首次正式安排误判为调整。 existing_transport 保留入参以兼容调用方;草稿复用由后续分支处理。 """ _ = existing_transport return plan.transport_status == "arranged" def _transport_has_db_links(db: Session, tenant_no: str, transport_id: str) -> bool: return ( db.query(PlTransportDbLink.id) .filter( PlTransportDbLink.tenant_no == tenant_no, PlTransportDbLink.transport_id == transport_id, PlTransportDbLink.del_flag == 0, ) .first() is not None ) def _apply_plan_logistics_from_payload(plan: PlPlan, payload: TransportArrange) -> None: if payload.pickup_address is not None: plan.pickup_address = payload.pickup_address elif payload.start_point is not None: plan.pickup_address = payload.start_point if "pickup_lng" in payload.model_fields_set or "pickup_lat" in payload.model_fields_set: plan.pickup_lng = payload.pickup_lng plan.pickup_lat = payload.pickup_lat if payload.pickup_time is not None: plan.pickup_time = payload.pickup_time if payload.pickup_contact is not None: plan.pickup_contact = payload.pickup_contact if payload.pickup_notes is not None: plan.pickup_notes = payload.pickup_notes if payload.destination_address is not None: plan.destination_address = payload.destination_address elif payload.end_point is not None: plan.destination_address = payload.end_point if "destination_lng" in payload.model_fields_set or "destination_lat" in payload.model_fields_set: plan.destination_lng = payload.destination_lng plan.destination_lat = payload.destination_lat if payload.delivery_time is not None: plan.delivery_time = payload.delivery_time if payload.delivery_contact is not None: plan.delivery_contact = payload.delivery_contact if payload.destination_notes is not None: plan.destination_notes = payload.destination_notes def _resolve_transport_endpoints(plan: PlPlan, payload: TransportArrange) -> tuple[Optional[str], Optional[str]]: start_point = ( payload.pickup_address or payload.start_point or plan.pickup_address ) end_point = ( payload.destination_address or payload.end_point or plan.destination_address or plan.destination_name ) return start_point, end_point def _apply_transport_arrange_fields( transport: PlTransport, *, payload: TransportArrange, vehicle_info: dict, driver_info: Optional[dict], carrier_name: Optional[str], start_point: Optional[str], end_point: Optional[str], freight_amount: Optional[float], user_id: str, ) -> None: transport.vehicle_id = payload.vehicle_id transport.license_plate = vehicle_info.get("license_plate") transport.driver_id = driver_info["id"] if driver_info else None transport.driver_name = driver_info["name"] if driver_info else None transport.driver_phone = driver_info["phone"] if driver_info else None transport.carrier_id = payload.carrier_id or vehicle_info.get("carrier_id") transport.carrier_name = carrier_name transport.transport_medium = payload.transport_medium transport.start_point = start_point transport.end_point = end_point transport.distance_km = payload.distance_km transport.unit = payload.unit or ("吨" if payload.freight_type == "by_weight" else "趟") transport.freight_price = payload.freight_price transport.freight_amount = freight_amount or 0 transport.freight_type = payload.freight_type if payload.has_invoice is not None: transport.has_invoice = payload.has_invoice if payload.remark is not None: transport.remark = payload.remark transport.update_by = user_id def _build_driver_url(token: str) -> str: origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/") return f"{origin}/#/public/pl/transport/{token}" def _build_arrange_result( transport: PlTransport, site_records: list[TransportSiteLinkRead], inventory_records: list[TransportInventoryLinkRead], io_order: Optional[TransportIoOrderBrief] = None, ) -> TransportArrangeResult: token = transport.driver_access_token or "" result = TransportArrangeResult.model_validate(transport) result.driver_access_token = token result.driver_url = _build_driver_url(token) if token else None result.site_records = site_records result.inventory_records = inventory_records result.io_order = io_order return result # ============================================================================= # 运输列表 # ============================================================================= @router.get("/", response_model=TransportListResponse) def list_transports( q: Optional[str] = Query(default=None, description="模糊搜索"), transport_no: Optional[str] = Query(default=None, alias="transportNo", description="运输编号"), transport_status: Optional[str] = Query(default=None, alias="transportStatus", description="运输状态"), approval_status: Optional[str] = Query(default=None, alias="approvalStatus", description="审批状态"), vehicle_id: Optional[str] = Query(default=None, alias="vehicleId", description="车辆ID"), driver_id: Optional[str] = Query(default=None, alias="driverId", description="司机ID"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> TransportListResponse: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) query = db.query(PlTransport).filter( PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0, ) if q: query = query.filter( or_( PlTransport.transport_no.contains(q), PlTransport.license_plate.contains(q), PlTransport.driver_name.contains(q), ) ) if transport_no: query = query.filter(PlTransport.transport_no.contains(transport_no)) if transport_status: query = query.filter(PlTransport.transport_status == transport_status) if approval_status: query = query.filter(PlTransport.approval_status == approval_status) if vehicle_id: query = query.filter(PlTransport.vehicle_id == vehicle_id) if driver_id: query = query.filter(PlTransport.driver_id == driver_id) query = apply_pl_scope(query, PlTransport, user, db, tenant_no) total = query.count() offset = (page - 1) * page_size transports = query.order_by(PlTransport.create_time.desc()).offset(offset).limit(page_size).all() return TransportListResponse(total=total, list=transports) # ============================================================================= # 创建运输 # ============================================================================= TRANSPORT_EXPORT_COLUMNS = [ ExportColumn(title="运输编号", field="transportNo"), ExportColumn(title="车牌号", field="licensePlate"), ExportColumn(title="司机", field="driverName"), ExportColumn(title="承运商", field="carrierName"), ExportColumn(title="运输介质", field="transportMedium"), ExportColumn(title="起点", field="startPoint"), ExportColumn(title="终点", field="endPoint"), ExportColumn(title="运输状态", field="transportStatus"), ExportColumn(title="审批状态", field="approvalStatus"), ExportColumn(title="备注", field="remark"), ] class TransportExportBody(BaseModel): q: Optional[str] = None transport_no: Optional[str] = Field(default=None, alias="transportNo") transport_status: Optional[str] = Field(default=None, alias="transportStatus") approval_status: Optional[str] = Field(default=None, alias="approvalStatus") vehicle_id: Optional[str] = Field(default=None, alias="vehicleId") driver_id: Optional[str] = Field(default=None, alias="driverId") ids: Optional[List[str]] = None columns: Optional[List[str]] = None model_config = {"populate_by_name": True} @router.post("/export") def export_transports( body: TransportExportBody, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ): """导出运输列表(当前筛选全量,或 ids 已选)。""" try: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) query = db.query(PlTransport).filter( PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0, ) if body.q: query = query.filter( or_( PlTransport.transport_no.contains(body.q), PlTransport.license_plate.contains(body.q), PlTransport.driver_name.contains(body.q), ) ) if body.transport_no: query = query.filter(PlTransport.transport_no.contains(body.transport_no)) if body.transport_status: query = query.filter(PlTransport.transport_status == body.transport_status) if body.approval_status: query = query.filter(PlTransport.approval_status == body.approval_status) if body.vehicle_id: query = query.filter(PlTransport.vehicle_id == body.vehicle_id) if body.driver_id: query = query.filter(PlTransport.driver_id == body.driver_id) if body.ids: query = query.filter(PlTransport.id.in_(body.ids)) query = apply_pl_scope(query, PlTransport, user, db, tenant_no) rows = query.order_by(PlTransport.create_time.desc()).limit(EXPORT_MAX_ROWS + 1).all() items = [TransportRead.model_validate(r).model_dump(by_alias=True) for r in rows] cols = filter_export_columns(TRANSPORT_EXPORT_COLUMNS, body.columns) date_str = datetime.now().strftime("%Y%m%d") return export_rows_to_xlsx_response( db, tenant_no=tenant_no, sheet_title="运输列表", columns=cols, rows=items, filename_stem=f"运输列表_{date_str}", ) except ExportValidationError as e: raise HTTPException(status_code=400, detail=str(e)) from e @router.post("/", response_model=TransportRead, status_code=status.HTTP_201_CREATED) def create_transport( payload: TransportCreate, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> TransportRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) transport_no = _generate_transport_no(db) transport = PlTransport( id=generate_uuid(), tenant_no=tenant_no, transport_no=transport_no, vehicle_id=payload.vehicle_id, license_plate=payload.license_plate, driver_id=payload.driver_id, driver_name=payload.driver_name, driver_phone=payload.driver_phone, carrier_id=payload.carrier_id, carrier_name=payload.carrier_name, transport_medium=payload.transport_medium, start_point=payload.start_point, end_point=payload.end_point, distance_km=payload.distance_km, unit=payload.unit, freight_price=payload.freight_price, freight_amount=payload.freight_amount, freight_type=payload.freight_type, has_invoice=payload.has_invoice, qr_expire_date=payload.qr_expire_date, transport_status=payload.transport_status or "draft", approval_status=payload.approval_status or "none", remark=payload.remark, create_by=user.id, create_dept_id=user.dept_id, ) db.add(transport) # 关联计划 if payload.plan_ids: for pid in payload.plan_ids: pt = PlPlanTransport( id=generate_uuid(), tenant_no=tenant_no, plan_id=pid, transport_id=transport.id, create_by=user.id, create_dept_id=user.dept_id, ) db.add(pt) # 更新计划关联的运输信息 plan = db.query(PlPlan).filter(PlPlan.id == pid, PlPlan.del_flag == 0).first() if plan: assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no) plan.transport_id = transport.id plan.transport_no = transport_no plan.vehicle_id = transport.vehicle_id plan.license_plate = transport.license_plate plan.driver_id = transport.driver_id plan.driver_name = transport.driver_name if transport.vehicle_id: snapshot = build_transport_resource_snapshot( db, tenant_no, transport.vehicle_id, selected_driver_id=transport.driver_id, ) apply_resource_snapshot_json( plan=plan, transport=transport, snapshot=snapshot ) db.commit() db.refresh(transport) return transport # ============================================================================= # 运输安排 # ============================================================================= @router.post("/arrange/{plan_id}", response_model=TransportArrangeResult, status_code=status.HTTP_201_CREATED) def arrange_transport( plan_id: str, payload: TransportArrange, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> TransportArrangeResult: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) # 获取计划 plan = db.query(PlPlan).filter( PlPlan.id == plan_id, PlPlan.tenant_no == tenant_no, PlPlan.del_flag == 0, ).first() if not plan: raise HTTPException(status_code=404, detail="计划不存在") assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no) existing_transport = None if plan.transport_id: existing_transport = db.query(PlTransport).filter( PlTransport.id == plan.transport_id, PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0, ).first() is_rearrange = _is_rearrange(plan, existing_transport) if is_rearrange and existing_transport and existing_transport.vehicle_id: _assert_vehicle_carrier_eligible( db, tenant_no, existing_transport.vehicle_id, rearrange=True, ) _assert_vehicle_carrier_eligible(db, tenant_no, payload.vehicle_id, rearrange=False) # 获取车辆信息 vehicle_info = _get_vehicle_info(db, payload.vehicle_id, tenant_no) # 获取司机信息(可选;driverId 为人员 pl_personnel.id) driver_info = None if payload.driver_id: driver_info = _resolve_driver_info(db, tenant_no, payload.driver_id) if not driver_info: raise HTTPException(status_code=400, detail="司机不存在") # 检查产品属性(易制毒/易制爆需要证件,以 CRM 产品主数据为准) products = db.query(PlPlanProduct).filter( PlPlanProduct.plan_id == plan_id, PlPlanProduct.del_flag == 0, ).all() validate_hazard_cert_ready_for_transport(db, tenant_no, plan, products) # 计算运费 freight_amount = payload.freight_amount if payload.freight_type == "by_weight" and payload.freight_price: total_qty = sum( float(p.quantity or p.inbound_estimate_qty or p.outbound_estimate_qty or 0) for p in products ) freight_amount = total_qty * float(payload.freight_price) carrier_name = vehicle_info.get("carrier_name") if payload.carrier_id: carrier = db.query(PlCarrier).filter(PlCarrier.id == payload.carrier_id).first() if carrier: carrier_name = carrier.carrier_name _apply_plan_logistics_from_payload(plan, payload) start_point, end_point = _resolve_transport_endpoints(plan, payload) if ( is_rearrange and existing_transport and _transport_has_db_links(db, tenant_no, existing_transport.id) ): try: revoke_regeneratable_dibang_for_transport( db, tenant_no=tenant_no, transport_id=existing_transport.id, user_id=user.id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if existing_transport and existing_transport.transport_status != "cancelled": transport = existing_transport _apply_transport_arrange_fields( transport, payload=payload, vehicle_info=vehicle_info, driver_info=driver_info, carrier_name=carrier_name, start_point=start_point, end_point=end_point, freight_amount=freight_amount, user_id=user.id, ) transport_no = transport.transport_no else: transport_no = _generate_transport_no(db) transport = PlTransport( id=generate_uuid(), tenant_no=tenant_no, transport_no=transport_no, transport_status="draft", approval_status="none", create_by=user.id, create_dept_id=user.dept_id, ) db.add(transport) _apply_transport_arrange_fields( transport, payload=payload, vehicle_info=vehicle_info, driver_info=driver_info, carrier_name=carrier_name, start_point=start_point, end_point=end_point, freight_amount=freight_amount, user_id=user.id, ) pt = PlPlanTransport( id=generate_uuid(), tenant_no=tenant_no, plan_id=plan_id, transport_id=transport.id, create_by=user.id, create_dept_id=user.dept_id, ) db.add(pt) plan.transport_id = transport.id plan.transport_no = transport_no plan.vehicle_id = transport.vehicle_id plan.license_plate = transport.license_plate plan.driver_id = transport.driver_id plan.driver_name = transport.driver_name if transport.vehicle_id: snapshot = build_transport_resource_snapshot( db, tenant_no, transport.vehicle_id, selected_driver_id=transport.driver_id, ) apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot) plan.transport_status = "arranged" plan.follow_status = "arranged" plan.freight_amount = freight_amount or 0 plan.update_by = user.id needs_site_link = plan_requires_site_link(db, tenant_no=tenant_no, plan=plan) if is_rearrange: _rotate_driver_access_token(transport) if needs_site_link and plan_has_load_stop(db, tenant_no, plan.id): rotate_transport_site_token(transport) elif not needs_site_link: transport.site_access_token = None if plan.plan_type in ("inbound", "outbound"): void_io_orders_for_plan( db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id, ) else: _ensure_driver_access_token(transport) site_records: list[TransportSiteLinkRead] = [] inventory_records: list[TransportInventoryLinkRead] = [] io_order_brief: Optional[TransportIoOrderBrief] = None if needs_site_link: use_multi_stop_site = plan_has_load_stop(db, tenant_no, plan.id) if use_multi_stop_site: site_token = ensure_transport_site_token(transport) site_url = build_transport_site_url(site_token) site_records = [ TransportSiteLinkRead( plan_product_id="", product_name=plan.plan_no or transport.transport_no, site_link_id=transport.id, plan_no=plan.plan_no or "", business_type="按段现场", site_access_token=site_token, site_url=site_url, ) ] elif plan.plan_type in ("inbound", "outbound"): try: if is_rearrange: # 调整运输:先作废旧出入库单(现场链接随之失效),再重建 void_io_orders_for_plan( db, tenant_no=tenant_no, plan_id=plan.id, user_id=user.id, ) io_result = create_io_order_for_transport( db, tenant_no=tenant_no, plan=plan, products=products, transport=transport, user_id=user.id, dept_id=user.dept_id or "", after_dibang=False, ) if io_result: io_order_brief = TransportIoOrderBrief( kind=io_result.kind, order_id=io_result.order_id, order_no=io_result.order_no, site_access_token=io_result.site_access_token, site_url=io_result.site_url, status="in_progress", ) # 兼容旧前端:合成单条 siteRecords 供复制现场链接 site_records = [ TransportSiteLinkRead( plan_product_id="", product_name=io_result.order_no, site_link_id=io_result.order_id, plan_no=plan.plan_no or "", business_type="入库" if io_result.kind == "inbound" else "出库", site_access_token=io_result.site_access_token, site_url=io_result.site_url, ) ] except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc use_multi_stop_dibang = plan_has_load_stop(db, tenant_no, plan.id) stop_dibang_options: list[StopDibangOption] = [] if use_multi_stop_dibang and payload.stop_dibang_options: stop_dibang_options = [ StopDibangOption( stop_type=item.stop_type, # type: ignore[arg-type] stop_id=item.stop_id, generate_dibang=item.generate_dibang, ) for item in payload.stop_dibang_options ] should_sync_legacy_dibang = ( not use_multi_stop_dibang and plan.plan_type in ("inbound", "outbound") and payload.generate_dibang_plan ) should_sync_stop_dibang = use_multi_stop_dibang and any( item.generate_dibang for item in stop_dibang_options ) if should_sync_stop_dibang or should_sync_legacy_dibang: try: if should_sync_stop_dibang: link_results = sync_dibang_for_plan_stops( db, tenant_no=tenant_no, plan=plan, transport=transport, stop_options=stop_dibang_options, user_id=user.id, dept_id=user.dept_id or "", ) else: link_results = sync_dibang_for_transport( db, tenant_no=tenant_no, plan=plan, products=products, transport=transport, user_id=user.id, dept_id=user.dept_id or "", ) inventory_records = [ build_transport_inventory_link_read( db, plan_product_id=item.plan_product_id, product_name=item.product_name, db_purchase_plan_id=item.db_purchase_plan_id, db_inventory_id=item.db_inventory_id, batch_no=item.batch_no, plan_no=item.plan_no, ) for item in link_results ] except ValueError as exc: db.rollback() raise HTTPException(status_code=400, detail=str(exc)) from exc apply_dibang_prefill_to_order( db, tenant_no=tenant_no, plan=plan, transport=transport ) db.commit() db.refresh(transport) return _build_arrange_result(transport, site_records, inventory_records, io_order_brief) # ============================================================================= # 获取运输详情 # ============================================================================= @router.get("/{id}", response_model=TransportRead) def get_transport( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> TransportRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) transport = _get_transport(db, tenant_no, id, user) return _transport_to_read(db, transport) # ============================================================================= # 生成二维码 # ============================================================================= class QrCodeResponse(BaseModel): transport_id: str = Field(alias="transportId") transport_no: str = Field(alias="transportNo") qr_code: str = Field(alias="qrCode") expire_date: Optional[date] = Field(default=None, alias="expireDate") model_config = {"from_attributes": True, "populate_by_name": True} @router.get("/{id}/qrcode", response_model=QrCodeResponse) def generate_qrcode( id: str, expire_days: int = Query(default=30, ge=1, le=365, description="有效期天数"), db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> QrCodeResponse: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) transport = _get_transport(db, tenant_no, id, user) from datetime import timedelta expire_date = date.today() + timedelta(days=expire_days) # 生成二维码内容(URL scheme) qr_content = f"/transport/scan/{transport.id}?code={transport.transport_no}" transport.qr_code = qr_content transport.qr_expire_date = expire_date db.commit() return QrCodeResponse( transport_id=transport.id, transport_no=transport.transport_no, qr_code=qr_content, expire_date=expire_date, ) # ============================================================================= # 司机扫码回传(无需登录) # ============================================================================= @router.post("/scan/feedback") def transport_scan_feedback( payload: TransportFeedback, db: Session = Depends(get_db), ) -> dict: """司机扫码回传,无需登录""" transport = ( db.query(PlTransport) .filter( PlTransport.transport_no == payload.transport_no, PlTransport.del_flag == 0, ) .first() ) if not transport: raise HTTPException(status_code=404, detail="运输单不存在") if payload.verify_code and payload.verify_code != transport.transport_no: raise HTTPException(status_code=400, detail="验证码错误") # 更新回传信息 if payload.feedback_qty: transport.feedback_qty = payload.feedback_qty if payload.feedback_images: transport.feedback_images = json.dumps(payload.feedback_images) if payload.feedback_remark: transport.feedback_remark = payload.feedback_remark transport.feedback_time = datetime.now() db.commit() return {"success": True, "message": "回传成功"} # ============================================================================= # 提交审批 # ============================================================================= @router.post("/{id}/submit-approval", response_model=TransportRead) def submit_approval( id: str, payload: ApprovalSubmit, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> TransportRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) transport = _get_transport(db, tenant_no, id, user) if transport.approval_status not in ("none", "rejected"): raise HTTPException(status_code=400, detail="当前状态不允许提交审批") # 创建审批记录 for idx, approver_id in enumerate(payload.approver_ids): from app.models.models import SysUser approver = db.query(SysUser).filter(SysUser.id == approver_id).first() if not approver: raise HTTPException(status_code=400, detail=f"审批人不存在: {approver_id}") approval = PlTransportApproval( id=generate_uuid(), tenant_no=tenant_no, transport_id=id, approver_id=approver_id, approver_name=approver.nick_name, approval_order=idx + 1, status="pending", create_by=user.id, create_dept_id=user.dept_id, ) db.add(approval) transport.approval_status = "pending" db.commit() db.refresh(transport) return transport # ============================================================================= # 审批 # ============================================================================= @router.post("/approval/{id}", response_model=ApprovalRecordRead) def process_approval( id: str, payload: ApprovalRequest, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> ApprovalRecordRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) approval = ( db.query(PlTransportApproval) .filter( PlTransportApproval.id == id, PlTransportApproval.tenant_no == tenant_no, PlTransportApproval.del_flag == 0, ) .first() ) if not approval: raise HTTPException(status_code=404, detail="审批记录不存在") if approval.status != "pending": raise HTTPException(status_code=400, detail="该审批已处理") # 更新审批状态 from datetime import datetime approval.status = "approved" if payload.action == "approve" else "rejected" approval.comment = payload.comment approval.approval_time = datetime.now() # 更新运输审批状态 transport = db.query(PlTransport).filter(PlTransport.id == approval.transport_id).first() if transport: if payload.action == "approve": # 检查是否所有审批人都通过了 pending = ( db.query(PlTransportApproval) .filter( PlTransportApproval.transport_id == approval.transport_id, PlTransportApproval.status == "pending", PlTransportApproval.del_flag == 0, ) .count() ) if pending == 0: transport.approval_status = "approved" transport.transport_status = "pending" else: transport.approval_status = "rejected" transport.transport_status = "draft" db.commit() db.refresh(approval) return approval # ============================================================================= # 获取运输的审批记录 # ============================================================================= @router.get("/{id}/approvals", response_model=list[ApprovalRecordRead]) def get_transport_approvals( id: str, db: Session = Depends(get_db), user: DbUserContext = Depends(get_db_current_user), ) -> list[ApprovalRecordRead]: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) approvals = ( db.query(PlTransportApproval) .filter( PlTransportApproval.transport_id == id, PlTransportApproval.tenant_no == tenant_no, PlTransportApproval.del_flag == 0, ) .order_by(PlTransportApproval.approval_order) .all() ) return approvals # ============================================================================= # 辅助函数 # ============================================================================= def _get_transport(db: Session, tenant_no: str, transport_id: str, user: DbUserContext) -> PlTransport: transport = ( db.query(PlTransport) .filter( PlTransport.id == transport_id, PlTransport.tenant_no == tenant_no, PlTransport.del_flag == 0, ) .first() ) if not transport: raise HTTPException(status_code=404, detail="运输不存在") assert_pl_row_in_scope(db, transport, PlTransport, user, tenant_no) return transport