Selaa lähdekoodia

feat(pl): optional escort selection on transport arrange

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 kuukausi sitten
vanhempi
commit
ca3062b0e2

+ 24 - 0
backend/app/api/pl/transport.py

@@ -439,6 +439,7 @@ def _apply_transport_arrange_fields(
     payload: TransportArrange,
     vehicle_info: dict,
     driver_info: Optional[dict],
+    escort_info: Optional[dict],
     carrier_name: Optional[str],
     start_point: Optional[str],
     end_point: Optional[str],
@@ -450,6 +451,9 @@ def _apply_transport_arrange_fields(
     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.escort_id = escort_info["id"] if escort_info else None
+    transport.escort_name = escort_info["name"] if escort_info else None
+    transport.escort_phone = escort_info["phone"] if escort_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
@@ -653,6 +657,9 @@ def create_transport(
         driver_id=payload.driver_id,
         driver_name=payload.driver_name,
         driver_phone=payload.driver_phone,
+        escort_id=payload.escort_id,
+        escort_name=payload.escort_name,
+        escort_phone=payload.escort_phone,
         carrier_id=payload.carrier_id,
         carrier_name=payload.carrier_name,
         transport_medium=payload.transport_medium,
@@ -696,12 +703,16 @@ def create_transport(
                 plan.license_plate = transport.license_plate
                 plan.driver_id = transport.driver_id
                 plan.driver_name = transport.driver_name
+                plan.escort_id = transport.escort_id
+                plan.escort_name = transport.escort_name
+                plan.escort_phone = transport.escort_phone
                 if transport.vehicle_id:
                     snapshot = build_transport_resource_snapshot(
                         db,
                         tenant_no,
                         transport.vehicle_id,
                         selected_driver_id=transport.driver_id,
+                        selected_escort_id=transport.escort_id,
                     )
                     apply_resource_snapshot_json(
                         plan=plan, transport=transport, snapshot=snapshot
@@ -766,6 +777,13 @@ def arrange_transport(
         if not driver_info:
             raise HTTPException(status_code=400, detail="司机不存在")
 
+    # 获取押运员信息(可选)
+    escort_info = None
+    if payload.escort_id:
+        escort_info = _resolve_driver_info(db, tenant_no, payload.escort_id)
+        if not escort_info:
+            raise HTTPException(status_code=400, detail="押运员不存在")
+
     # 检查产品属性(易制毒/易制爆需要证件,以 CRM 产品主数据为准)
     products = db.query(PlPlanProduct).filter(
         PlPlanProduct.plan_id == plan_id,
@@ -811,6 +829,7 @@ def arrange_transport(
             payload=payload,
             vehicle_info=vehicle_info,
             driver_info=driver_info,
+            escort_info=escort_info,
             carrier_name=carrier_name,
             start_point=start_point,
             end_point=end_point,
@@ -835,6 +854,7 @@ def arrange_transport(
             payload=payload,
             vehicle_info=vehicle_info,
             driver_info=driver_info,
+            escort_info=escort_info,
             carrier_name=carrier_name,
             start_point=start_point,
             end_point=end_point,
@@ -858,12 +878,16 @@ def arrange_transport(
     plan.license_plate = transport.license_plate
     plan.driver_id = transport.driver_id
     plan.driver_name = transport.driver_name
+    plan.escort_id = transport.escort_id
+    plan.escort_name = transport.escort_name
+    plan.escort_phone = transport.escort_phone
     if transport.vehicle_id:
         snapshot = build_transport_resource_snapshot(
             db,
             tenant_no,
             transport.vehicle_id,
             selected_driver_id=transport.driver_id,
+            selected_escort_id=transport.escort_id,
         )
         apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
     plan.transport_status = "arranged"

+ 4 - 0
backend/app/schemas/pl/plan.py

@@ -287,6 +287,7 @@ class PlanCreate(PlanBase):
 class PlanTransportCreateBlock(BaseModel):
     vehicle_id: str = Field(..., alias="vehicleId", description="车辆ID")
     driver_id: Optional[str] = Field(default=None, alias="driverId", description="司机/人员ID")
+    escort_id: Optional[str] = Field(default=None, alias="escortId", description="押运员人员ID(可选)")
     carrier_id: Optional[str] = Field(default=None, alias="carrierId", description="承运商ID")
     distance_km: Optional[Decimal] = Field(default=None, alias="distanceKm", description="运输公里数")
     freight_type: str = Field(default="by_weight", alias="freightType", description="运费计算方式")
@@ -384,6 +385,9 @@ class PlanRead(PlanBase, PlanResourceSnapshotMixin):
     license_plate: Optional[str] = Field(default=None, alias="licensePlate")
     driver_id: Optional[str] = Field(default=None, alias="driverId")
     driver_name: Optional[str] = Field(default=None, alias="driverName")
+    escort_id: Optional[str] = Field(default=None, alias="escortId")
+    escort_name: Optional[str] = Field(default=None, alias="escortName")
+    escort_phone: Optional[str] = Field(default=None, alias="escortPhone")
     personnel_summary: Optional[str] = Field(default=None, alias="personnelSummary")
     transport_id: Optional[str] = Field(default=None, alias="transportId")
     transport_no: Optional[str] = Field(default=None, alias="transportNo")

+ 7 - 0
backend/app/schemas/pl/transport.py

@@ -21,6 +21,9 @@ class TransportBase(BaseModel):
     driver_id: Optional[str] = Field(default=None, alias="driverId", description="司机ID")
     driver_name: Optional[str] = Field(default=None, max_length=100, alias="driverName", description="司机姓名")
     driver_phone: Optional[str] = Field(default=None, max_length=32, alias="driverPhone", description="司机电话")
+    escort_id: Optional[str] = Field(default=None, alias="escortId", description="押运员人员ID")
+    escort_name: Optional[str] = Field(default=None, max_length=100, alias="escortName", description="押运员姓名")
+    escort_phone: Optional[str] = Field(default=None, max_length=32, alias="escortPhone", description="押运员电话")
     carrier_id: Optional[str] = Field(default=None, alias="carrierId", description="承运商ID")
     carrier_name: Optional[str] = Field(default=None, max_length=200, alias="carrierName", description="承运商名称")
     transport_medium: Optional[str] = Field(default=None, max_length=100, alias="transportMedium", description="运输介质")
@@ -54,6 +57,7 @@ class TransportStopDibangOption(BaseModel):
 class TransportArrange(BaseModel):
     vehicle_id: str = Field(..., alias="vehicleId", description="车辆ID")
     driver_id: Optional[str] = Field(default=None, alias="driverId", description="司机ID(可选)")
+    escort_id: Optional[str] = Field(default=None, alias="escortId", description="押运员人员ID(可选)")
     carrier_id: Optional[str] = Field(default=None, alias="carrierId", description="承运商ID")
     transport_medium: Optional[str] = Field(default=None, alias="transportMedium", description="运输介质")
     pickup_address: Optional[str] = Field(default=None, alias="pickupAddress", description="取货地址")
@@ -128,6 +132,9 @@ class TransportUpdate(BaseModel):
     driver_id: Optional[str] = Field(default=None, alias="driverId")
     driver_name: Optional[str] = Field(default=None, max_length=100, alias="driverName")
     driver_phone: Optional[str] = Field(default=None, max_length=32, alias="driverPhone")
+    escort_id: Optional[str] = Field(default=None, alias="escortId")
+    escort_name: Optional[str] = Field(default=None, max_length=100, alias="escortName")
+    escort_phone: Optional[str] = Field(default=None, max_length=32, alias="escortPhone")
     carrier_id: Optional[str] = Field(default=None, alias="carrierId")
     carrier_name: Optional[str] = Field(default=None, max_length=200, alias="carrierName")
     transport_medium: Optional[str] = Field(default=None, max_length=100, alias="transportMedium")

+ 75 - 6
backend/app/services/pl_plan_service.py

@@ -225,28 +225,32 @@ def _apply_vehicle_snapshot_to_plan_transport(
     transport: Optional[PlTransport],
     vehicle: PlVehicle,
     driver_id: Optional[str],
+    escort_id: Optional[str] = None,
 ) -> None:
     snapshot = build_transport_resource_snapshot(
         db,
         tenant_no,
         vehicle.id,
         selected_driver_id=driver_id,
+        selected_escort_id=escort_id,
     )
     apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot)
 
 
-def _resolve_driver(
+def _resolve_personnel_by_vehicle_role(
     db: Session,
     tenant_no: str,
     vehicle_id: str,
-    driver_id: Optional[str],
+    personnel_id: Optional[str],
+    *,
+    role: str,
 ) -> tuple[Optional[str], Optional[str], Optional[str]]:
-    """解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。"""
-    if driver_id:
+    """解析驾押人员:显式选择优先,否则取车辆关联该角色;均无则允许为空。"""
+    if personnel_id:
         personnel = (
             db.query(PlPersonnel)
             .filter(
-                PlPersonnel.id == driver_id,
+                PlPersonnel.id == personnel_id,
                 PlPersonnel.tenant_no == tenant_no,
                 PlPersonnel.del_flag == 0,
             )
@@ -261,7 +265,7 @@ def _resolve_driver(
             PlVehiclePersonnel.vehicle_id == vehicle_id,
             PlVehiclePersonnel.tenant_no == tenant_no,
             PlVehiclePersonnel.del_flag == 0,
-            PlVehiclePersonnel.role == "driver",
+            PlVehiclePersonnel.role == role,
         )
         .order_by(PlVehiclePersonnel.is_default.desc(), PlVehiclePersonnel.order_num.asc())
         .first()
@@ -281,6 +285,30 @@ def _resolve_driver(
     return None, None, None
 
 
+def _resolve_driver(
+    db: Session,
+    tenant_no: str,
+    vehicle_id: str,
+    driver_id: Optional[str],
+) -> tuple[Optional[str], Optional[str], Optional[str]]:
+    """解析司机:显式选择优先,否则取车辆关联司机;均无则允许为空。"""
+    return _resolve_personnel_by_vehicle_role(
+        db, tenant_no, vehicle_id, driver_id, role="driver"
+    )
+
+
+def _resolve_escort(
+    db: Session,
+    tenant_no: str,
+    vehicle_id: str,
+    escort_id: Optional[str],
+) -> tuple[Optional[str], Optional[str], Optional[str]]:
+    """解析押运员:显式选择优先,否则取车辆关联押运;均无则允许为空。"""
+    return _resolve_personnel_by_vehicle_role(
+        db, tenant_no, vehicle_id, escort_id, role="escort"
+    )
+
+
 def _calc_freight_amount(
     freight_type: str,
     products: list[PlPlanProduct],
@@ -732,6 +760,9 @@ def _resolve_transport_context(
     Optional[str],
     Optional[str],
     Optional[str],
+    Optional[str],
+    Optional[str],
+    Optional[str],
     Decimal,
     Optional[Decimal],
 ]:
@@ -750,6 +781,9 @@ def _resolve_transport_context(
     driver_id, driver_name, driver_phone = _resolve_driver(
         db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
     )
+    escort_id, escort_name, escort_phone = _resolve_escort(
+        db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_id
+    )
     carrier_id = transport_payload.carrier_id or vehicle.carrier_id
     carrier_name = vehicle.carrier_name
     if carrier_id:
@@ -777,6 +811,9 @@ def _resolve_transport_context(
         driver_id,
         driver_name,
         driver_phone,
+        escort_id,
+        escort_name,
+        escort_phone,
         carrier_id,
         carrier_name,
         freight_amount,
@@ -791,6 +828,9 @@ def _apply_payload_to_transport_row(
     driver_id: Optional[str],
     driver_name: Optional[str],
     driver_phone: Optional[str],
+    escort_id: Optional[str],
+    escort_name: Optional[str],
+    escort_phone: Optional[str],
     carrier_id: Optional[str],
     carrier_name: Optional[str],
     plan: PlPlan,
@@ -804,6 +844,9 @@ def _apply_payload_to_transport_row(
     transport.driver_id = driver_id
     transport.driver_name = driver_name
     transport.driver_phone = driver_phone
+    transport.escort_id = escort_id
+    transport.escort_name = escort_name
+    transport.escort_phone = escort_phone
     transport.carrier_id = carrier_id
     transport.carrier_name = carrier_name
     transport.start_point = plan.pickup_address
@@ -837,6 +880,9 @@ def upsert_draft_transport_for_plan(
         driver_id,
         driver_name,
         driver_phone,
+        escort_id,
+        escort_name,
+        escort_phone,
         carrier_id,
         carrier_name,
         freight_amount,
@@ -868,6 +914,9 @@ def upsert_draft_transport_for_plan(
             driver_id=driver_id,
             driver_name=driver_name,
             driver_phone=driver_phone,
+            escort_id=escort_id,
+            escort_name=escort_name,
+            escort_phone=escort_phone,
             carrier_id=carrier_id,
             carrier_name=carrier_name,
             plan=plan,
@@ -910,6 +959,9 @@ def upsert_draft_transport_for_plan(
             driver_id=driver_id,
             driver_name=driver_name,
             driver_phone=driver_phone,
+            escort_id=escort_id,
+            escort_name=escort_name,
+            escort_phone=escort_phone,
             carrier_id=carrier_id,
             carrier_name=carrier_name,
             plan=plan,
@@ -923,6 +975,9 @@ def upsert_draft_transport_for_plan(
     plan.license_plate = vehicle.license_plate
     plan.driver_id = driver_id
     plan.driver_name = driver_name
+    plan.escort_id = escort_id
+    plan.escort_name = escort_name
+    plan.escort_phone = escort_phone
     _apply_vehicle_snapshot_to_plan_transport(
         db,
         tenant_no=tenant_no,
@@ -930,6 +985,7 @@ def upsert_draft_transport_for_plan(
         transport=transport,
         vehicle=vehicle,
         driver_id=driver_id,
+        escort_id=escort_id,
     )
     plan.freight_amount = freight_amount or 0
     if plan.transport_id:
@@ -979,6 +1035,9 @@ def clear_draft_transport_for_plan(
     plan.license_plate = None
     plan.driver_id = None
     plan.driver_name = None
+    plan.escort_id = None
+    plan.escort_name = None
+    plan.escort_phone = None
     clear_resource_snapshot(plan=plan, transport=transport)
     plan.freight_amount = 0
     if plan.transport_status != "arranged":
@@ -1057,6 +1116,9 @@ def create_transport_for_plan(
     driver_id, driver_name, driver_phone = _resolve_driver(
         db, tenant_no, transport_payload.vehicle_id, transport_payload.driver_id
     )
+    escort_id, escort_name, escort_phone = _resolve_escort(
+        db, tenant_no, transport_payload.vehicle_id, transport_payload.escort_id
+    )
 
     carrier_name = vehicle.carrier_name
     carrier_id = transport_payload.carrier_id or vehicle.carrier_id
@@ -1091,6 +1153,9 @@ def create_transport_for_plan(
         driver_id=driver_id,
         driver_name=driver_name,
         driver_phone=driver_phone,
+        escort_id=escort_id,
+        escort_name=escort_name,
+        escort_phone=escort_phone,
         carrier_id=carrier_id,
         carrier_name=carrier_name,
         start_point=plan.pickup_address,
@@ -1127,6 +1192,9 @@ def create_transport_for_plan(
     plan.license_plate = vehicle.license_plate
     plan.driver_id = driver_id
     plan.driver_name = driver_name
+    plan.escort_id = escort_id
+    plan.escort_name = escort_name
+    plan.escort_phone = escort_phone
     _apply_vehicle_snapshot_to_plan_transport(
         db,
         tenant_no=tenant_no,
@@ -1134,6 +1202,7 @@ def create_transport_for_plan(
         transport=transport,
         vehicle=vehicle,
         driver_id=driver_id,
+        escort_id=escort_id,
     )
     plan.transport_status = "arranged"
     plan.follow_status = "arranged"

+ 28 - 0
backend/tests/test_pl_plan_resolve_escort.py

@@ -0,0 +1,28 @@
+"""运输计划解析押运员:车辆未关联押运时允许为空。"""
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from app.services.pl_plan_service import _resolve_escort
+
+
+def test_resolve_escort_without_vehicle_link_returns_none():
+    db = MagicMock()
+    db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
+
+    result = _resolve_escort(db, "tenant-1", "vehicle-1", None)
+
+    assert result == (None, None, None)
+
+
+def test_resolve_escort_with_explicit_id_returns_personnel():
+    db = MagicMock()
+    personnel = MagicMock()
+    personnel.id = "escort-1"
+    personnel.personnel_name = "王押"
+    personnel.phone_no = "13900000001"
+    db.query.return_value.filter.return_value.first.return_value = personnel
+
+    result = _resolve_escort(db, "tenant-1", "vehicle-1", "escort-1")
+
+    assert result == ("escort-1", "王押", "13900000001")

+ 1 - 0
backend/tests/test_pl_transport_arrange_optional_driver.py

@@ -43,6 +43,7 @@ def test_apply_transport_arrange_fields_without_driver():
         payload=payload,
         vehicle_info={"license_plate": "粤A12345", "carrier_id": None},
         driver_info=None,
+        escort_info=None,
         carrier_name=None,
         start_point="A",
         end_point="B",

+ 115 - 0
backend/tests/test_pl_transport_arrange_optional_escort.py

@@ -0,0 +1,115 @@
+"""安排运输:押运员可选。"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from app.api.pl.transport import _apply_transport_arrange_fields
+from app.schemas.pl.plan import PlanTransportCreateBlock
+from app.schemas.pl.transport import TransportArrange
+
+
+def test_transport_arrange_schema_allows_escort_id():
+    payload = TransportArrange.model_validate(
+        {"vehicleId": "veh-1", "escortId": "escort-1"}
+    )
+    assert payload.escort_id == "escort-1"
+    assert payload.driver_id is None
+
+
+def test_plan_transport_block_allows_escort_id():
+    block = PlanTransportCreateBlock.model_validate(
+        {"vehicleId": "veh-1", "escortId": "escort-9"}
+    )
+    assert block.escort_id == "escort-9"
+
+
+def test_apply_transport_arrange_fields_sets_escort():
+    transport = SimpleNamespace(
+        vehicle_id=None,
+        license_plate=None,
+        driver_id=None,
+        driver_name=None,
+        driver_phone=None,
+        escort_id=None,
+        escort_name=None,
+        escort_phone=None,
+        carrier_id=None,
+        carrier_name=None,
+        transport_medium=None,
+        start_point=None,
+        end_point=None,
+        distance_km=None,
+        unit=None,
+        freight_price=None,
+        freight_amount=0,
+        freight_type="by_weight",
+        has_invoice=0,
+        remark=None,
+        update_by=None,
+    )
+    payload = TransportArrange.model_validate(
+        {"vehicleId": "veh-1", "freightType": "by_weight", "escortId": "e1"}
+    )
+
+    _apply_transport_arrange_fields(
+        transport,
+        payload=payload,
+        vehicle_info={"license_plate": "粤A12345", "carrier_id": None},
+        driver_info=None,
+        escort_info={"id": "e1", "name": "李押", "phone": "13900000000"},
+        carrier_name=None,
+        start_point="A",
+        end_point="B",
+        freight_amount=0,
+        user_id="u1",
+    )
+
+    assert transport.escort_id == "e1"
+    assert transport.escort_name == "李押"
+    assert transport.escort_phone == "13900000000"
+
+
+def test_apply_transport_arrange_fields_clears_escort():
+    transport = SimpleNamespace(
+        vehicle_id="veh-old",
+        license_plate="旧牌",
+        driver_id=None,
+        driver_name=None,
+        driver_phone=None,
+        escort_id="old-e",
+        escort_name="旧押",
+        escort_phone="1",
+        carrier_id=None,
+        carrier_name=None,
+        transport_medium=None,
+        start_point=None,
+        end_point=None,
+        distance_km=None,
+        unit=None,
+        freight_price=None,
+        freight_amount=0,
+        freight_type="by_weight",
+        has_invoice=0,
+        remark=None,
+        update_by=None,
+    )
+    payload = TransportArrange.model_validate(
+        {"vehicleId": "veh-1", "freightType": "by_weight"}
+    )
+
+    _apply_transport_arrange_fields(
+        transport,
+        payload=payload,
+        vehicle_info={"license_plate": "粤A12345", "carrier_id": None},
+        driver_info=None,
+        escort_info=None,
+        carrier_name=None,
+        start_point="A",
+        end_point="B",
+        freight_amount=0,
+        user_id="u1",
+    )
+
+    assert transport.escort_id is None
+    assert transport.escort_name is None
+    assert transport.escort_phone is None

+ 10 - 0
web-admin/src/api/pl/index.ts

@@ -194,6 +194,8 @@ export interface PersonnelQuery {
   personnelAffiliation?: string
   /** driver | escort;不传则不过滤类型 */
   personnelType?: string
+  /** 逗号分隔多类型,如 escort,both */
+  personnelTypeIn?: string
   page?: number
   pageSize?: number
 }
@@ -578,6 +580,9 @@ export interface Plan {
   trailerNo?: string
   driverId?: string
   driverName?: string
+  escortId?: string
+  escortName?: string
+  escortPhone?: string
   personnelSummary?: string
   resourceSnapshot?: TransportResourceSnapshot
   transportId?: string
@@ -658,6 +663,7 @@ export interface PlanListResponse {
 export interface PlanTransportCreateBlock {
   vehicleId: string
   driverId?: string
+  escortId?: string
   carrierId?: string
   distanceKm?: number
   freightType: string
@@ -1053,6 +1059,9 @@ export interface Transport {
   driverId?: string
   driverName?: string
   driverPhone?: string
+  escortId?: string
+  escortName?: string
+  escortPhone?: string
   resourceSnapshot?: TransportResourceSnapshot
   carrierId?: string
   carrierName?: string
@@ -1090,6 +1099,7 @@ export interface TransportStopDibangOption {
 export interface TransportArrange {
   vehicleId: string
   driverId?: string
+  escortId?: string
   carrierId?: string
   transportMedium?: string
   pickupAddress?: string

+ 73 - 4
web-admin/src/components/business/pl-plan-form-dialog/index.vue

@@ -80,7 +80,7 @@
           label-width="100px"
           class="pl-dialog-form"
         >
-          <div class="pl-plan-transport-row pl-plan-transport-row--three">
+          <div class="pl-plan-transport-row pl-plan-transport-row--four">
             <el-form-item label="车牌号" class="pl-plan-transport-row__vehicle">
               <div class="pl-plan-vehicle-with-library">
                 <el-select
@@ -136,6 +136,26 @@
                 />
               </el-select>
             </el-form-item>
+            <el-form-item label="选择押运员" class="pl-plan-transport-row__escort">
+              <el-select
+                v-model="selectedEscortId"
+                placeholder="请选择押运员(可选)"
+                filterable
+                remote
+                clearable
+                :remote-method="searchEscorts"
+                :loading="escortSelectLoading"
+                :disabled="!formData.vehicleId"
+                @focus="onEscortSelectFocus"
+              >
+                <el-option
+                  v-for="d in escortList"
+                  :key="d.id"
+                  :label="formatDriverOptionLabel(d)"
+                  :value="d.id"
+                />
+              </el-select>
+            </el-form-item>
             <el-form-item label="运输公司" class="pl-plan-transport-row__carrier">
               <div class="pl-plan-carrier-with-library">
                 <el-select
@@ -509,6 +529,7 @@
   const deliveryForms = ref<PlanDeliveryForm[]>([createEmptyDeliveryForm(1)])
   const loadedPlanSnapshot = ref<Plan | null>(null)
   const selectedDriverId = ref('')
+  const selectedEscortId = ref('')
 
   /** 已安排:禁止仅「保存」,须走保存并安排/调整运输,避免计划与地磅等联动字段脱节 */
   const showSaveButton = computed(() => {
@@ -664,6 +685,16 @@
     getSelectedId: () => selectedDriverId.value,
     listParams: { status: '1', personnelType: 'driver' }
   })
+  const {
+    list: escortList,
+    loading: escortSelectLoading,
+    search: searchEscorts,
+    onFocus: onEscortSelectFocus,
+    ensureById: ensureSelectedEscortOption
+  } = usePlRemotePersonnelOptions({
+    getSelectedId: () => selectedEscortId.value,
+    listParams: { status: '1', personnelTypeIn: 'escort,both' }
+  })
   const vehicleBundle = ref<VehicleViewBundle | null>(null)
 
   const formData = reactive({
@@ -1509,6 +1540,7 @@
     return {
       vehicleId: formData.vehicleId,
       driverId: selectedDriverId.value || undefined,
+      escortId: selectedEscortId.value || undefined,
       carrierId: formData.carrierId || undefined,
       distanceKm: formData.distanceKm ?? undefined,
       freightType: formData.freightType,
@@ -1580,6 +1612,7 @@
         freightAmount?: number
         carrierId?: string
         driverId?: string
+        escortId?: string
       }
       formData.distanceKm = transport.distanceKm != null ? Number(transport.distanceKm) : null
       formData.freightType = transport.freightType || 'by_weight'
@@ -1589,6 +1622,9 @@
       if (transport.driverId) {
         selectedDriverId.value = transport.driverId
       }
+      if (transport.escortId) {
+        selectedEscortId.value = transport.escortId
+      }
       freightCalc.freightPrice.value =
         transport.freightPrice != null ? Number(transport.freightPrice) : null
       freightCalc.freightAmount.value =
@@ -1660,6 +1696,7 @@
       plan,
       vehicleId: formData.vehicleId,
       driverId: selectedDriverId.value,
+      escortId: selectedEscortId.value,
       carrierId: formData.carrierId,
       loadForm: loadForm.value,
       deliveryForms: deliveryForms.value,
@@ -1689,6 +1726,7 @@
     loadedPlanSnapshot.value = null
     vehicleBundle.value = null
     selectedDriverId.value = ''
+    selectedEscortId.value = ''
     const d = todayYmd()
     Object.assign(formData, {
       createDate: d,
@@ -1794,11 +1832,17 @@
       if (data.driverId) {
         selectedDriverId.value = data.driverId
       }
+      if (data.escortId) {
+        selectedEscortId.value = data.escortId
+      }
       freightCalc.resetFreightCalc()
       await restoreFreightFromTransport(data)
       if (selectedDriverId.value) {
         await ensureSelectedDriverOption(selectedDriverId.value)
       }
+      if (selectedEscortId.value) {
+        await ensureSelectedEscortOption(selectedEscortId.value)
+      }
       if (formData.carrierId) {
         await ensureSelectedCarrierOption(formData.carrierId)
       }
@@ -1865,6 +1909,7 @@
       formData.vehicleId = ''
       formData.carrierId = ''
       selectedDriverId.value = ''
+      selectedEscortId.value = ''
       vehicleBundle.value = null
       reapplyStopDibangByVehiclePlate('')
       freightCalc.resetFreightCalc()
@@ -1886,7 +1931,13 @@
   }
 
   async function loadMasterData() {
-    await Promise.all([searchVehicles(''), searchCarriers(''), searchDrivers(''), loadDefaultCustomerOptions()])
+    await Promise.all([
+      searchVehicles(''),
+      searchCarriers(''),
+      searchDrivers(''),
+      searchEscorts(''),
+      loadDefaultCustomerOptions()
+    ])
   }
 
   function resolveDefaultDriver(personnelList: VehiclePersonnelRow[]) {
@@ -1895,6 +1946,12 @@
     selectedDriverId.value = defaultDriver?.personnelId || ''
   }
 
+  function resolveDefaultEscort(personnelList: VehiclePersonnelRow[]) {
+    const escorts = personnelList.filter((item) => item.role === 'escort' && item.personnelId)
+    const defaultEscort = escorts.find((item) => item.isDefault === 1) || escorts[0]
+    selectedEscortId.value = defaultEscort?.personnelId || ''
+  }
+
   async function handleVehicleChange(
     vehicleId: string,
     opts?: { syncStopDibang?: boolean }
@@ -1902,6 +1959,7 @@
     const syncStopDibang = opts?.syncStopDibang !== false
     vehicleBundle.value = null
     selectedDriverId.value = ''
+    selectedEscortId.value = ''
     if (!vehicleId) {
       formData.carrierId = ''
       if (syncStopDibang) reapplyStopDibangByVehiclePlate('')
@@ -1922,9 +1980,13 @@
         formData.carrierId = vehicleBundle.value.detail.carrierId
       }
       resolveDefaultDriver(vehicleBundle.value.personnelList || [])
+      resolveDefaultEscort(vehicleBundle.value.personnelList || [])
       if (selectedDriverId.value) {
         await ensureSelectedDriverOption(selectedDriverId.value)
       }
+      if (selectedEscortId.value) {
+        await ensureSelectedEscortOption(selectedEscortId.value)
+      }
     } finally {
       vehicleLoading.value = false
     }
@@ -2306,14 +2368,21 @@
     gap: 12px 16px;
   }
 
+  .pl-plan-transport-row--four {
+    display: grid;
+    grid-template-columns: max-content minmax(180px, 240px) minmax(180px, 240px) minmax(240px, 1fr);
+    gap: 12px 16px;
+  }
+
   .pl-plan-transport-row__vehicle {
     width: max-content;
     max-width: 100%;
   }
 
-  .pl-plan-transport-row__driver {
+  .pl-plan-transport-row__driver,
+  .pl-plan-transport-row__escort {
     min-width: 0;
-    max-width: 340px;
+    max-width: 280px;
   }
 
   .pl-plan-transport-row__carrier {

+ 2 - 0
web-admin/src/composables/usePlPlanArrangeSubmit.ts

@@ -99,6 +99,7 @@ export interface BuildArrangePayloadInput {
   plan: Plan
   vehicleId: string
   driverId: string
+  escortId: string
   carrierId: string
   loadForm: PlanLoadForm
   deliveryForms: PlanDeliveryForm[]
@@ -128,6 +129,7 @@ export function buildArrangePayloadFromPlanForm(input: BuildArrangePayloadInput)
   return {
     vehicleId: input.vehicleId,
     driverId: input.driverId || undefined,
+    escortId: input.escortId || undefined,
     carrierId: input.carrierId || undefined,
     pickupAddress: input.loadForm.address || undefined,
     pickupLng: input.loadForm.lng,