fix(lastleg): expose dispatch reviews and filter available capacity offers

This commit is contained in:
2026-10-08 14:11:55 +08:00
parent 1cb1f2d0c0
commit 136a2bc3b5
14 changed files with 194 additions and 18 deletions
@@ -15,10 +15,22 @@ public final class DispatchRequests {
}
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
String vehiclePlate, String status, String fbaAppointmentNo) {
String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public enum Stage {
BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED;
public List<String> statuses() {
return switch (this) {
case BEFORE_LOADING -> List.of("PENDING_DEPARTURE");
case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART");
case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED");
case CANCELLED -> List.of("CANCELLED");
};
}
}
public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus,
@@ -12,7 +12,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines) {
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -75,6 +75,7 @@ public class DispatchService {
@Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
@@ -623,11 +624,16 @@ public class DispatchService {
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
.in(query.stage() != null, DispatchOrder::getStatus,
query.stage() == null ? List.of() : query.stage().statuses())
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt));
var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream()
.map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.<String, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow>of();
List<DispatchResponse> records = page.getRecords().stream()
.map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList();
.map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()),
List.of(), workflows.get(order.getDispatchNo()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@@ -764,6 +770,11 @@ public class DispatchService {
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList) {
return response(order, itemList, bolLineList, null);
}
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -782,7 +793,7 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList);
bolLineList == null ? List.of() : bolLineList, workflow);
}
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -7,7 +7,9 @@ public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferE
<script>
SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id
<where>
<if test="salesOnly">o.status='OPEN'</if>
<if test="availableOnly">o.status='OPEN' AND t.status='PLANNED'
AND o.cutoff_at &gt; #{now} AND o.cutoff_at &lt;= t.planned_departure_at
</if>
<if test="q.status != null">AND o.status=#{q.status}</if>
<if test="q.tripNo != null and q.tripNo != ''">AND o.trip_no=#{q.tripNo}</if>
<if test="q.keyword != null and q.keyword != ''">AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%'))</if>
@@ -21,7 +23,7 @@ public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferE
""")
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> pageOffers(
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> page,
@Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly);
@Param("q") FleetCapacityOfferModels.Query query,@Param("availableOnly") boolean availableOnly,@Param("now") java.time.LocalDateTime now);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}")
FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE")
@@ -16,8 +16,8 @@ public final class FleetCapacityOfferModels {
public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
@Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination,
LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status,
Boolean canConfirm,@Size(max=64) String tripNo) {
public Query {page=page==null?1:page;size=size==null?20:size;}
Boolean canConfirm,@Size(max=64) String tripNo,@Pattern(regexp="AVAILABLE|ALL") String scope) {
public Query {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"AVAILABLE":scope;}
}
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
@@ -52,7 +52,8 @@ public class FleetCapacityOfferService {
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
readPermission();
var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage());
if("ALL".equals(query.scope())&&!manage())throw forbidden();
var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!"ALL".equals(query.scope()),LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC));
if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围");
var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false)))
.filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList();
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.stream.Collectors;
/** Read existing reviews in batches; never infer approval from dispatch execution status. */
@Service
public class FleetDispatchWorkflowQuery {
public record Review(String status, String reason, java.time.Instant reviewedAt) { }
public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { }
private final FleetTripDispatchMapper bindings;
private final FleetTripMapper trips;
private final ObjectMapper json;
public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) {
this.bindings = bindings; this.trips = trips; this.json = json;
}
public Map<String, Workflow> read(List<String> dispatchNos) {
if (dispatchNos.isEmpty()) return Map.of();
var members = bindings.findByDispatchNos(dispatchNos);
if (members.isEmpty()) return Map.of();
var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList())
.stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip));
Map<String, Workflow> result = new HashMap<>();
for (var member : members) {
var trip = parents.get(member.getTripId());
if (trip == null) throw new IllegalStateException("关联车次不存在");
result.put(member.getDispatchNo(), new Workflow(member.getTripNo(),
review(member.getReviewJson()), review(trip.getLoadingReviewJson())));
}
return result;
}
private Review review(String value) {
var state = FleetTripReview.read(json, value);
return new Review(state.status(), state.reason(), state.reviewedAt());
}
}
@@ -92,6 +92,10 @@ paths:
- OPEN
- PAUSED
- CLOSED
- name: scope
in: query
description: 默认AVAILABLE,仅开放且车次仍PLANNED、当前服务端时间早于交货截止且截止不晚于计划发车;数据库分页前筛选,不以容量已知或有余位为条件。ALL包含草稿和历史,仅管理权限可用;车次发布设置读取已有发布须显式传ALL。
schema: { type: string, enum: [AVAILABLE, ALL], default: AVAILABLE }
- name: canConfirm
in: query
required: false
@@ -586,6 +586,10 @@ paths:
- { name: vehiclePlate, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: fbaAppointmentNo, in: query, schema: { type: string } }
- name: stage
in: query
description: 服务端在分页前筛选;BEFORE_LOADING=待发车未拣货,LOADING=拣货中或拣货完成待发车,AFTER_LOADING=已发车(含部分),CANCELLED=已取消;与status取交集。
schema: { type: string, enum: [BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED] }
responses:
'200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse }
/api/platform/v1/last-leg/dispatches/{dispatchNo}:
@@ -2947,10 +2951,28 @@ components:
partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
workflow:
nullable: true
description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。
allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }]
bolLines:
type: array
description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
items: { $ref: '#/components/schemas/DispatchBolLine' }
DispatchWorkflow:
type: object
required: [tripNo, dispatchReview, loadingReview]
properties:
tripNo: { type: string }
dispatchReview: { $ref: '#/components/schemas/DispatchReview' }
loadingReview: { $ref: '#/components/schemas/DispatchReview' }
DispatchReview:
type: object
required: [status]
properties:
status: { type: string, enum: [PENDING, APPROVED, REJECTED] }
reason: { type: string, nullable: true }
reviewedAt: { type: string, format: date-time, nullable: true }
DispatchItem:
type: object
properties:
@@ -38,7 +38,7 @@ class DispatchQueryControllerTest {
@Test
void platformPageQueriesAcrossCustomersWithoutCustomerId() {
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null);
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null, null);
when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(platform(), query);
@@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest {
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
"NYEXX2609110214", null, null, 1)));
"NYEXX2609110214", null, null, 1)), null);
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
@@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest {
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
"NYEXX2609110214", null, null, 1),
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
"NYEXX2609110215", null, null, 1)));
"NYEXX2609110215", null, null, 1)), null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE");
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
@@ -63,11 +63,13 @@ class DispatchServiceTest {
@Mock LastlegOrderMapper tailOrders;
@Mock OutboundOrderMapper outboundOrders;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
private DispatchService service;
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
@@ -435,6 +437,24 @@ class DispatchServiceTest {
verify(bolLines).findByDispatchOrder("200", 1L);
}
@Test
void filtersStageBeforePaginationAndPreservesTheServerTotal() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "test"), DispatchOrder.class);
Page<DispatchOrder> resultPage = new Page<>(2, 10, 47);
resultPage.setRecords(List.of());
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenAnswer(invocation -> {
LambdaQueryWrapper<DispatchOrder> wrapper = invocation.getArgument(1);
assertTrue(wrapper.getSqlSegment().contains("status IN"));
assertTrue(wrapper.getParamNameValuePairs().values().containsAll(
List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART")));
return resultPage;
});
var result = service.platformPage(new DispatchRequests.Query(2, 10, null, null, null, null, DispatchRequests.Stage.LOADING));
assertEquals(47, result.total());
assertEquals(List.of("DEPARTED", "PARTIAL_DEPARTED"), DispatchRequests.Stage.AFTER_LOADING.statuses());
}
@Test
void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() {
DispatchOrder first = new DispatchOrder();
@@ -445,7 +465,7 @@ class DispatchServiceTest {
resultPage.setRecords(List.of(first, second));
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage);
var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null));
var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null));
assertEquals(2, result.total());
verify(items).findByDispatchOrder("100", 1L);
@@ -36,22 +36,50 @@ class FleetCapacityOfferServiceTest {
@AfterEach void clear(){SecurityContextHolder.clearContext();}
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
var rows=List.of(offer(1),offer(2));var page=new Page<FleetCapacityOfferEntity>(3,20,2000);page.setRecords(rows);
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(page);
when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(page);
for(var row:rows){var trip=trip(row.getTripNo());when(trips.findByNo(row.getTripNo())).thenReturn(trip);
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("ONT8",null,"FBA:ONT8")));
when(execution.capacityPlan(row.getTripNo())).thenReturn(new FleetExecutionModels.Planning(row.getTripNo(),1L,List.of(),"hash",null,false,"计划待核对",List.of()));}
var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null);
var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null,null);
var result=service.page(query);
assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2);
var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true));
assertThat(result.records()).allMatch(FleetCapacityOfferModels.Offer::canCreateInquiry).allMatch(value -> !value.canConfirm());
var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC)));
assertThat(captor.getValue().getCurrent()).isEqualTo(3);assertThat(captor.getValue().getSize()).isEqualTo(20);
verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips);
}
@Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101));
assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null))).hasMessageContaining("100");
when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(new Page<>(1,100,101));
assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null,null))).hasMessageContaining("100");
verifyNoInteractions(execution,trips);
}
@Test void historyScopeRequiresManagementAndDefaultScopeIsAvailable(){
var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null);
assertThat(query.scope()).isEqualTo("AVAILABLE");
var history=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,"ALL");
assertThatThrownBy(()->service.page(history)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
verifyNoInteractions(offers);
var user=new AuthenticatedUser(101L,0L,0L,101L,"admin","ADMIN","WEB",Set.of(FleetCapacityOfferService.MANAGE));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.MANAGE))));
when(offers.pageOffers(any(),same(query),eq(true),any())).thenReturn(new Page<>(1,20,0));
service.page(query);
verify(offers).pageOffers(any(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC)));
when(offers.pageOffers(any(),same(history),eq(false),any())).thenReturn(new Page<>(1,20,0));
assertThat(service.page(history).records()).isEmpty();
verify(offers).pageOffers(any(),same(history),eq(false),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC)));
}
@Test void availableSqlFiltersBeforePaginationWithoutRequiringKnownCapacity() throws Exception {
var method=Arrays.stream(FleetCapacityOfferMapper.class.getMethods()).filter(m->m.getName().equals("pageOffers")).findFirst().orElseThrow();
String script=String.join("\n",method.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
var sql=new org.apache.ibatis.scripting.xmltags.XMLLanguageDriver().createSqlSource(new org.apache.ibatis.session.Configuration(),script,Map.class);
var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null);
var parameters=new HashMap<String,Object>();parameters.put("q",query);parameters.put("availableOnly",true);parameters.put("now",LocalDateTime.ofInstant(now,ZoneOffset.UTC));
String active=sql.getBoundSql(parameters).getSql().replaceAll("\\s+"," ");
assertThat(active).contains("o.status='OPEN'", "t.status='PLANNED'", "o.cutoff_at > ?", "o.cutoff_at <= t.planned_departure_at");
assertThat(active).doesNotContain("planned_load_json", "remaining", "NOW()");
parameters.put("availableOnly",false);
assertThat(sql.getBoundSql(parameters).getSql()).doesNotContain("o.cutoff_at", "t.status='PLANNED'");
}
@Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){
var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN");
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull();
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
class FleetDispatchWorkflowQueryTest {
@Test
void batchesReviewsAndKeepsCustomerDispatchDecisionsIndependent() {
var bindings = mock(FleetTripDispatchMapper.class);
var trips = mock(FleetTripMapper.class);
var first = new FleetTripDispatch(); first.setDispatchNo("PC-A"); first.setTripId(1L); first.setTripNo("TR-A");
first.setReviewJson("{\"status\":\"REJECTED\",\"reason\":\"货物需重新核对\"}");
var second = new FleetTripDispatch(); second.setDispatchNo("PC-B"); second.setTripId(1L); second.setTripNo("TR-A");
var trip = new FleetTripEntity(); trip.setId(1L);
trip.setLoadingReviewJson("{\"status\":\"APPROVED\",\"reason\":\"审核通过\"}");
when(bindings.findByDispatchNos(List.of("PC-A", "PC-B", "PC-C"))).thenReturn(List.of(first, second));
when(trips.selectBatchIds(any())).thenReturn(List.of(trip));
var query = new FleetDispatchWorkflowQuery(bindings, trips, new ObjectMapper().findAndRegisterModules());
var result = query.read(List.of("PC-A", "PC-B", "PC-C"));
assertEquals("REJECTED", result.get("PC-A").dispatchReview().status());
assertEquals("货物需重新核对", result.get("PC-A").dispatchReview().reason());
assertEquals("PENDING", result.get("PC-B").dispatchReview().status());
assertEquals("APPROVED", result.get("PC-B").loadingReview().status());
assertFalse(result.containsKey("PC-C"));
verify(trips).selectBatchIds(List.of(1L));
verifyNoMoreInteractions(trips);
assertTrue(query.read(List.of()).isEmpty());
}
}