diff --git a/backend/app/api/pl/transport.py b/backend/app/api/pl/transport.py index 78eb449bf..353333a14 100644 --- a/backend/app/api/pl/transport.py +++ b/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" diff --git a/backend/app/schemas/pl/plan.py b/backend/app/schemas/pl/plan.py index 6ebeface1..1e69e6384 100644 --- a/backend/app/schemas/pl/plan.py +++ b/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") diff --git a/backend/app/schemas/pl/transport.py b/backend/app/schemas/pl/transport.py index 749adc989..aef6ba88f 100644 --- a/backend/app/schemas/pl/transport.py +++ b/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") diff --git a/backend/app/services/pl_plan_service.py b/backend/app/services/pl_plan_service.py index ad93027c5..57d40632d 100644 --- a/backend/app/services/pl_plan_service.py +++ b/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" diff --git a/backend/tests/test_pl_plan_resolve_escort.py b/backend/tests/test_pl_plan_resolve_escort.py new file mode 100644 index 000000000..ae8cf776c --- /dev/null +++ b/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") diff --git a/backend/tests/test_pl_transport_arrange_optional_driver.py b/backend/tests/test_pl_transport_arrange_optional_driver.py index b437f3f1a..5f307a2c9 100644 --- a/backend/tests/test_pl_transport_arrange_optional_driver.py +++ b/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", diff --git a/backend/tests/test_pl_transport_arrange_optional_escort.py b/backend/tests/test_pl_transport_arrange_optional_escort.py new file mode 100644 index 000000000..22b9ac476 --- /dev/null +++ b/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 diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index 84ab201d6..60d2d546d 100644 --- a/web-admin/src/api/pl/index.ts +++ b/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 diff --git a/web-admin/src/components/business/pl-plan-form-dialog/index.vue b/web-admin/src/components/business/pl-plan-form-dialog/index.vue index c607f57d6..8ddc0fec9 100644 --- a/web-admin/src/components/business/pl-plan-form-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-form-dialog/index.vue @@ -80,7 +80,7 @@ label-width="100px" class="pl-dialog-form" > -