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, 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 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, public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds,
@Size(max = 64) String fbaAppointmentNo, @Size(max = 64) String fbaAppointmentNo,
@Size(max = 32) String fbaAppointmentStatus, @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 itemCount, int palletCount, int packageCount, int pickedPackageCount,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt, 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, public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName, String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -75,6 +75,7 @@ public class DispatchService {
@Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderMapper tailOrders;
@Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundOrderMapper outboundOrders;
@Resource private FleetDispatchBindingGuard fleetBindings; @Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Resource private RelabelApplicationMapper relabelApplications; @Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @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.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) .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, .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
query.fbaAppointmentNo()) query.fbaAppointmentNo())
.orderByDesc(DispatchOrder::getCreatedAt)); .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() 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()); 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, private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList,
List<DispatchResponse.BolLine> bolLineList) { 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()) String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
.distinct().collect(Collectors.joining(",")); .distinct().collect(Collectors.joining(","));
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
@@ -782,7 +793,7 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), 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) { private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -7,7 +7,9 @@ public interface FleetCapacityOfferMapper extends BaseMapper<FleetCapacityOfferE
<script> <script>
SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id
<where> <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.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.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> <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> pageOffers(
com.baomidou.mybatisplus.extension.plugins.pagination.Page<FleetCapacityOfferEntity> page, 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}") @Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}")
FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo); FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo);
@Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE") @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, 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, @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, LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status,
Boolean canConfirm,@Size(max=64) String tripNo) { 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;} 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, 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, @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) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){ public PageResult<FleetCapacityOfferModels.Offer> page(FleetCapacityOfferModels.Query query){
readPermission(); 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趟车次,请缩小起运仓、目的地或日期范围"); if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围");
var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false))) var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false)))
.filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList(); .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 - OPEN
- PAUSED - PAUSED
- CLOSED - CLOSED
- name: scope
in: query
description: 默认AVAILABLE,仅开放且车次仍PLANNED、当前服务端时间早于交货截止且截止不晚于计划发车;数据库分页前筛选,不以容量已知或有余位为条件。ALL包含草稿和历史,仅管理权限可用;车次发布设置读取已有发布须显式传ALL。
schema: { type: string, enum: [AVAILABLE, ALL], default: AVAILABLE }
- name: canConfirm - name: canConfirm
in: query in: query
required: false required: false
@@ -586,6 +586,10 @@ paths:
- { name: vehiclePlate, in: query, schema: { type: string } } - { name: vehiclePlate, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } }
- { name: fbaAppointmentNo, 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: responses:
'200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse }
/api/platform/v1/last-leg/dispatches/{dispatchNo}: /api/platform/v1/last-leg/dispatches/{dispatchNo}:
@@ -2947,10 +2951,28 @@ components:
partialCompletionReason: { type: string, nullable: true } partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
workflow:
nullable: true
description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。
allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }]
bolLines: bolLines:
type: array type: array
description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大
items: { $ref: '#/components/schemas/DispatchBolLine' } 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: DispatchItem:
type: object type: object
properties: properties:
@@ -38,7 +38,7 @@ class DispatchQueryControllerTest {
@Test @Test
void platformPageQueriesAcrossCustomersWithoutCustomerId() { 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)); when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(platform(), query); 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", 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(), 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", 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", var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
null, "91730", "10767 7th Street", null); null, "91730", "10767 7th Street", null);
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
@@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest {
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
"NYEXX2609110214", null, null, 1), "NYEXX2609110214", null, null, 1),
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", 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, var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE");
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
@@ -63,11 +63,13 @@ class DispatchServiceTest {
@Mock LastlegOrderMapper tailOrders; @Mock LastlegOrderMapper tailOrders;
@Mock OutboundOrderMapper outboundOrders; @Mock OutboundOrderMapper outboundOrders;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
private DispatchService service; private DispatchService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new DispatchService(); service = new DispatchService();
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items); ReflectionTestUtils.setField(service, "items", items);
@@ -435,6 +437,24 @@ class DispatchServiceTest {
verify(bolLines).findByDispatchOrder("200", 1L); 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 @Test
void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() { void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() {
DispatchOrder first = new DispatchOrder(); DispatchOrder first = new DispatchOrder();
@@ -445,7 +465,7 @@ class DispatchServiceTest {
resultPage.setRecords(List.of(first, second)); resultPage.setRecords(List.of(first, second));
when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); 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()); assertEquals(2, result.total());
verify(items).findByDispatchOrder("100", 1L); verify(items).findByDispatchOrder("100", 1L);
@@ -36,22 +36,50 @@ class FleetCapacityOfferServiceTest {
@AfterEach void clear(){SecurityContextHolder.clearContext();} @AfterEach void clear(){SecurityContextHolder.clearContext();}
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){ @Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
var rows=List.of(offer(1),offer(2));var page=new Page<FleetCapacityOfferEntity>(3,20,2000);page.setRecords(rows); 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); 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(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()));} 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); var result=service.page(query);
assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2); 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); 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); verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips);
} }
@Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){ @Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){
when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101)); 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))).hasMessageContaining("100"); assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null,null))).hasMessageContaining("100");
verifyNoInteractions(execution,trips); 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(){ @Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){
var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN"); var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN");
assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull(); 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());
}
}