feat(fleet): query vehicle delivery and reassignment history

This commit is contained in:
2026-10-03 10:10:55 +08:00
parent d93185e750
commit 30559fd4eb
8 changed files with 420 additions and 0 deletions
+2
View File
@@ -75,6 +75,8 @@
## 海外仓全流程开发路由
- 车辆履约只读投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同 `src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml`。车辆路径下`deliveries`同时要求vehicle:list与delivery:list,`assignment-history`同时要求vehicle:list与trip:list;不新增权限码或跨库查询。计划派送归当前分配车辆,实际运输归不可变DEPART快照;客户与排车双键关联。更换读取相邻版本ASSIGN快照,首次分配不算更换,旧车/新车/仅换司机分别呈现,不按当前车牌猜历史。列表分页在只读重复读事务内核对;内部ID及整份客户快照不直接返回。
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}")
public class PlatformVehicleFulfillmentController {
private final VehicleFulfillmentService service;
public PlatformVehicleFulfillmentController(VehicleFulfillmentService service) { this.service = service; }
@GetMapping("/deliveries")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:overseas-warehouse:delivery:list')")
public ApiResponse<PageResult<VehicleFulfillmentModels.Delivery>> deliveries(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) {
return ApiResponse.success(service.deliveries(vehicleId, query));
}
@GetMapping("/assignment-history")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<VehicleFulfillmentModels.Change>> changes(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) {
return ApiResponse.success(service.changes(vehicleId, query));
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.fleet.trip;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */
@Mapper
public interface VehicleFulfillmentMapper {
String DEPARTED = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' "
+ "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})";
String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b "
+ "ON b.dispatch_id=d.dispatch_order_id AND b.customer_id=d.customer_id "
+ "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")";
String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))";
String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))";
String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id "
+ "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 "
+ "WHERE h.action='ASSIGN' AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' "
+ "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) "
+ "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE
+ " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))";
@Select("SELECT COUNT(*)" + DELIVERIES)
long countDeliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey);
@Select("SELECT d.delivery_no,d.dispatch_no,t.trip_no,d.bol_no,d.customer_name,d.destination_type,"
+ "d.destination_warehouse_code,d.destination_warehouse_name,d.delivery_address,d.planned_delivery_at,d.status,"
+ "CASE WHEN " + DEPARTED + " THEN 'DEPARTED' ELSE 'PLANNED' END AS relationship" + DELIVERIES
+ " ORDER BY d.created_at DESC,d.id DESC LIMIT #{size} OFFSET #{offset}")
List<VehicleFulfillmentModels.Delivery> deliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey,
@Param("offset") long offset, @Param("size") int size);
@Select("SELECT COUNT(*)" + CHANGES)
long countChanges(@Param("vehicleKey") String vehicleKey);
@Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot,h.snapshot_json AS after_snapshot"
+ CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}")
List<VehicleFulfillmentModels.ChangeRow> changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.fleet.trip;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
public final class VehicleFulfillmentModels {
private VehicleFulfillmentModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) {
public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; }
public long offset() { return ((long) page - 1) * size; }
}
public record Delivery(String deliveryNo, String dispatchNo, String tripNo, String bolNo,
String customerName, String destinationType, String destinationWarehouseCode,
String destinationWarehouseName, String deliveryAddress,
LocalDateTime plannedDeliveryAt, String status, String relationship) { }
public record ChangeRow(String tripNo, long recordVersion, LocalDateTime occurredAt, String reason,
String beforeSnapshot, String afterSnapshot) { }
public record Change(String tripNo, long recordVersion, Instant occurredAt, String reason,
String previousVehiclePlate, String vehiclePlate,
String previousDriverName, String driverName, String relationship,
List<String> dispatchNos) { }
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZoneOffset;
import java.util.ArrayList;
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class VehicleFulfillmentService {
private final VehicleRepository vehicles;
private final VehicleFulfillmentMapper mapper;
private final ObjectMapper json;
public VehicleFulfillmentService(VehicleRepository vehicles, VehicleFulfillmentMapper mapper, ObjectMapper json) {
this.vehicles = vehicles; this.mapper = mapper; this.json = json;
}
public PageResult<VehicleFulfillmentModels.Delivery> deliveries(long vehicleId, VehicleFulfillmentModels.Query query) {
requireVehicle(vehicleId); String key = Long.toString(vehicleId);
long total = mapper.countDeliveries(vehicleId, key);
return new PageResult<>(mapper.deliveries(vehicleId, key, query.offset(), query.size()), query.page(), query.size(), total, pages(total, query.size()));
}
public PageResult<VehicleFulfillmentModels.Change> changes(long vehicleId, VehicleFulfillmentModels.Query query) {
requireVehicle(vehicleId); String key = Long.toString(vehicleId);
long total = mapper.countChanges(key);
var records = mapper.changes(key, query.offset(), query.size()).stream().map(row -> change(key, row)).toList();
return new PageResult<>(records, query.page(), query.size(), total, pages(total, query.size()));
}
private VehicleFulfillmentModels.Change change(String key, VehicleFulfillmentModels.ChangeRow row) {
try {
JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip");
if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照");
var dispatches = new ArrayList<String>();
for (JsonNode binding : snapshot.path("dispatches")) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText());
String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED"
: key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN";
return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(),
text(before, "vehiclePlate"), text(after, "vehiclePlate"), text(before, "driverName"), text(after, "driverName"), relationship, java.util.List.copyOf(dispatches));
} catch (JsonProcessingException error) { throw new IllegalStateException("车辆更换历史快照无法读取", error); }
}
private static String text(JsonNode value, String key) { return value.hasNonNull(key) ? value.get(key).asText() : null; }
private static long pages(long total, int size) { return total / size + (total % size == 0 ? 0 : 1); }
private void requireVehicle(long vehicleId) {
vehicles.findById(vehicleId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆不存在"));
}
}
@@ -0,0 +1,105 @@
openapi: 3.0.3
info:
title: 车辆履约与更换历史
version: 1.0.0
description: >-
平台按稳定车辆ID读取Lastleg本库履约事实,不按当前车牌文本反查,不修改单据或历史。
计划中的派送归当前分配车辆;实际发车后按不可变DEPART历史中的车辆身份归属。
关联排车和派送时同时匹配客户归属,不返回财务、附件、联系人或内部数据库主键。
更换记录依据相邻车次版本的ASSIGN快照,首次分配和资源未变化的操作不算更换。
旧车和新车均能读取同一更换事实;仅换司机也保留。返回历史车牌/姓名,不用当前主档覆盖。
不生成原型示例记录;没有正式车次关联的旧派送不通过车牌猜测归属。
security: [{bearerAuth: []}]
paths:
/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/deliveries:
parameters:
- {$ref: '#/components/parameters/Vehicle'}
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
get:
summary: 当前车辆关联派送记录
description: '同时要求 platform:overseas-master-data:vehicle:list 与 platform:overseas-warehouse:delivery:list。按派送创建时间、内部排序键倒序分页;排序键不返回。'
responses:
'200':
description: '统一ApiResponse,成功code为200,data为分页;车辆不存在返回业务404语义。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
allOf:
- {$ref: '#/components/schemas/Page'}
- type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/Delivery'}}
/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/assignment-history:
parameters:
- {$ref: '#/components/parameters/Vehicle'}
- {$ref: '#/components/parameters/Page'}
- {$ref: '#/components/parameters/Size'}
get:
summary: 当前车辆相关的换车与换司机事实
description: '同时要求 platform:overseas-master-data:vehicle:list 与 platform:fleet:trip:list。按事件时间、历史排序键倒序分页,一行一个变更事件;关联排车使用该事件快照,不使用当前关联覆盖历史。'
responses:
'200':
description: '统一ApiResponse,data为分页。历史记录只含稳定业务编号及更换事实,不公开整份客户快照或操作人内部ID。'
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
allOf:
- {$ref: '#/components/schemas/Page'}
- type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/Change'}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters:
Vehicle: {name: vehicleId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 10}}
schemas:
Page:
type: object
required: [records, page, size, total, pages]
properties:
page: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
Delivery:
type: object
required: [deliveryNo, dispatchNo, tripNo, status, relationship]
properties:
deliveryNo: {type: string}
dispatchNo: {type: string}
tripNo: {type: string}
bolNo: {type: string, nullable: true}
customerName: {type: string, nullable: true}
destinationType: {type: string}
destinationWarehouseCode: {type: string, nullable: true}
destinationWarehouseName: {type: string, nullable: true}
deliveryAddress: {type: string, nullable: true}
plannedDeliveryAt: {type: string, nullable: true, description: 服务端作业时区的本地日期时间}
status: {type: string}
relationship: {type: string, enum: [PLANNED, DEPARTED]}
Change:
type: object
required: [tripNo, recordVersion, occurredAt, relationship, dispatchNos]
properties:
tripNo: {type: string}
recordVersion: {type: string, pattern: '^[0-9]+$', description: 车次版本字符串,与车次号组成事件标识}
occurredAt: {type: string, format: date-time}
reason: {type: string, nullable: true}
previousVehiclePlate: {type: string, nullable: true}
vehiclePlate: {type: string, nullable: true}
previousDriverName: {type: string, nullable: true}
driverName: {type: string, nullable: true}
relationship: {type: string, enum: [REPLACED_OUT, REPLACED_IN, DRIVER_CHANGED]}
dispatchNos: {type: array, items: {type: string}}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.fleet.trip;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleFulfillmentContractTest {
private static final String VEHICLE="platform:overseas-master-data:vehicle:list", DELIVERY="platform:overseas-warehouse:delivery:list", TRIP="platform:fleet:trip:list";
private void login(String... authorities) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList()));
}
@Test void vehicleViewingDoesNotGrantDeliveryOrTripDataAndEachConsumerNeedsItsOwnReadPermission() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformVehicleFulfillmentController.class);
var service=context.getBean(VehicleFulfillmentService.class);
var query=new VehicleFulfillmentModels.Query(1,10);
for(String[] granted:List.of(new String[]{VEHICLE},new String[]{DELIVERY,TRIP},new String[]{})) {
login(granted);
assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class);
}
verifyNoInteractions(service);
login(VEHICLE,DELIVERY); controller.deliveries(8,query);
assertThatThrownBy(()->controller.changes(8,query)).isInstanceOf(AccessDeniedException.class);
login(VEHICLE,TRIP); controller.changes(8,query);
assertThatThrownBy(()->controller.deliveries(8,query)).isInstanceOf(AccessDeniedException.class);
verify(service).deliveries(8,query); verify(service).changes(8,query); verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Test void publicGetOperationsAndResponseFieldsAreDocumentedAndQueriesAreReadOnly() throws Exception {
Map<?,?> document=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-fulfillment-v1.yaml")));
Map<?,?> paths=(Map<?,?>)document.get("paths");
String base=PlatformVehicleFulfillmentController.class.getAnnotation(RequestMapping.class).value()[0];
for(var method:PlatformVehicleFulfillmentController.class.getDeclaredMethods()) if(method.isAnnotationPresent(GetMapping.class)) {
String path=base+method.getAnnotation(GetMapping.class).value()[0];
Map<?,?> get=(Map<?,?>)((Map<?,?>)paths.get(path)).get("get");
String expression=method.getAnnotation(PreAuthorize.class).value();
for(String permission:List.of(VEHICLE,DELIVERY,TRIP)) if(expression.contains(permission)) assertThat(get.get("description").toString()).contains(permission);
}
Map<?,?> schemas=(Map<?,?>)((Map<?,?>)document.get("components")).get("schemas");
for(var type:List.of(VehicleFulfillmentModels.Delivery.class,VehicleFulfillmentModels.Change.class)) {
Map<?,?> properties=(Map<?,?>)((Map<?,?>)schemas.get(type.getSimpleName())).get("properties");
assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder(Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new));
}
assertThat(VehicleFulfillmentService.class.getAnnotation(Transactional.class).readOnly()).isTrue();
}
@Configuration @EnableMethodSecurity static class Config {
@Bean VehicleFulfillmentService service(){return mock(VehicleFulfillmentService.class);}
@Bean PlatformVehicleFulfillmentController controller(VehicleFulfillmentService service){return new PlatformVehicleFulfillmentController(service);}
}
}
@@ -0,0 +1,105 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */
public class VehicleFulfillmentQueryTest {
private JdbcTemplate jdbc;
private VehicleFulfillmentMapper mapper;
private VehicleFulfillmentService service;
private VehicleRepository vehicles;
private static final ObjectMapper JSON = new ObjectMapper();
public static String jsonExtract(String source, String path) throws Exception {
if (source == null) return null;
var value = JSON.readTree(source);
for (String field : path.substring(2).split("\\.")) value = value.path(field);
return value.isMissingNode() ? null : value.toString();
}
public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); }
@BeforeEach void setup() throws Exception {
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))");
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)");
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
mapper = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())).getMapper(VehicleFulfillmentMapper.class);
vehicles = mock(VehicleRepository.class);
when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
service = new VehicleFulfillmentService(vehicles, mapper, JSON);
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')");
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')");
delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B");
}
private void delivery(long deliveryId, long dispatchId, String customer) {
jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')",
deliveryId,"DL-"+deliveryId,"DP-"+dispatchId,dispatchId,customer,customer);
}
private void history(long version, String action, String vehicle, String driver) throws Exception {
var trip = new HashMap<String,Object>();
if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); }
if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); }
String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12"))));
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot);
}
@Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() {
delivery(23,11,"WRONG-CUSTOMER");
var result = service.deliveries(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).extracting(VehicleFulfillmentModels.Delivery::customerName).containsExactly("CUSTOMER-B","CUSTOMER-A");
assertThat(result.records()).allMatch(row -> row.relationship().equals("PLANNED"));
assertThat(service.deliveries(7,new VehicleFulfillmentModels.Query(1,10)).records()).isEmpty();
}
@Test void departedVehicleIsReadFromImmutableSnapshotAfterCurrentAssignmentChanges() throws Exception {
history(0,"DEPART","8","18"); history(1,"COMPLETE","8","18");
jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='COMPLETED'");
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,1)).records()).singleElement().satisfies(row -> {
assertThat(row.deliveryNo()).isEqualTo("DL-22"); assertThat(row.relationship()).isEqualTo("DEPARTED");
});
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(2,1)).records()).extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-21");
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
assertThat(service.deliveries(18,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
}
@Test void cancelledUnexecutedTripIsNotPresentedAsActualTransport() {
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(null,null)).total()).isZero();
}
@Test void initialAssignmentIsNotAReplacementAndBothOldAndNewVehiclesRetainReplacement() throws Exception {
history(0,"CREATE",null,null); history(1,"ASSIGN","7","17"); history(2,"ASSIGN","8","18");
var out = service.changes(7,new VehicleFulfillmentModels.Query(1,10));
var in = service.changes(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(out.total()).isEqualTo(1); assertThat(in.total()).isEqualTo(1);
assertThat(out.records().get(0).relationship()).isEqualTo("REPLACED_OUT");
assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN");
assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7");
assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8");
assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12");
assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z");
assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
}
@Test void driverOnlyReplacementIsVisibleForSameVehicleAndNoOpIsExcluded() throws Exception {
history(0,"ASSIGN","8","17"); history(1,"ASSIGN","8","18"); history(2,"ASSIGN","8","18");
var result=service.changes(8,new VehicleFulfillmentModels.Query(1,10));
assertThat(result.total()).isEqualTo(1); assertThat(result.records().get(0).relationship()).isEqualTo("DRIVER_CHANGED");
}
@Test void nonexistentVehicleCannotBeUsedToQueryOrLeakHistory() {
when(vehicles.findById(404L)).thenReturn(Optional.empty());
assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
}
}