feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -10,6 +10,7 @@ import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DispatchCandidateMapperContractTest {
@@ -131,7 +132,7 @@ class DispatchCandidateMapperContractTest {
}
@Test
void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception {
void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception {
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
String.class, String.class, String.class),
@@ -142,7 +143,7 @@ class DispatchCandidateMapperContractTest {
assertTrue(sql.contains("ll_inbound_order inbound"));
assertTrue(sql.contains("a.allocated_volume_cbm"));
assertTrue(sql.contains("a2.line_id=a.line_id"));
assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg"));
assertFalse(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg"));
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.dispatch;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DispatchPhysicalWeightTest {
@Test void billingVolumeWeightCannotFillMissingPhysicalWeight() {
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))");
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)");
int tested = 0;
for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) {
var annotation = method.getAnnotation(Select.class);
if (annotation == null) continue;
String sql = String.join(" ", annotation.value()).replace("&gt;", ">").replace("&lt;", "<");
int from = sql.indexOf("AS volumeWeightKg,");
int to = sql.indexOf(" AS grossWeightKg,");
if (from < 0 || to < 0) continue;
String projection = sql.substring(from + "AS volumeWeightKg,".length(), to);
String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip "
+ "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l "
+ "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a "
+ "CROSS JOIN (SELECT 999 AS volume_weight_kg) o";
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull();
if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet.
jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)");
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20");
String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10");
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull();
jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)");
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40");
jdbc.update("DELETE FROM ll_order_detail");
tested++;
}
assertThat(tested).isEqualTo(2);
}
}
@@ -32,7 +32,8 @@ class SmartDispatchOpenApiContractTest {
assertTrue(yaml.contains("SmartDispatchPreArrange"));
assertTrue(yaml.contains("SmartDispatchCandidate"));
assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码"));
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重"));
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重"));
assertTrue(yaml.contains("禁止使用体积重替代"));
String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"),
yaml.indexOf(" SmartDispatchDraftPallet:"));
String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"),
@@ -40,6 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,12 +59,14 @@ class DispatchPdaWorkflowServiceTest {
@Mock InventoryPositionMapper positions;
@Mock InventoryCommandService inventory;
@Mock DispatchPickOrderAttributionService attribution;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
private DispatchPdaWorkflowService service;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
service = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
objectMapper = new ObjectMapper();
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
@@ -167,6 +171,7 @@ class DispatchPdaWorkflowServiceTest {
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
assertEquals(2, result.packages());
verify(fleetBindings, never()).requirePickingApproved(anyLong());
verify(inventory, never()).allocate(any(), any(), any());
verify(picks, never()).insert((DispatchPdaPick) any());
verify(items, never()).bindReceivedPallets(any(), any());
@@ -231,12 +236,36 @@ class DispatchPdaWorkflowServiceTest {
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)));
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
assertEquals(409, exception.getStatusCode().value());
verify(outboundOrders, never()).updateById((OutboundOrder) any());
}
@Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货"))
.when(fleetBindings).requirePickingApproved(1L);
assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null)));
assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete")));
org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution);
verify(orders, never()).updateById((DispatchOrder) any());
}
@Test void boundDispatchCannotDepartBeforeTripCoordination() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100");
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
when(orders.lockById("100", 1L)).thenReturn(order);
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车"))
.when(fleetBindings).requireCoordinatedDeparture(1L);
assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders);
verify(orders, never()).updateById((DispatchOrder) any());
}
@Test
void successfulDeparturePersistsBusinessDepartureTime() {
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
@@ -262,7 +291,7 @@ class DispatchPdaWorkflowServiceTest {
when(palletLocks.selectList(any())).thenReturn(List.of());
DispatchPdaResponses.Departed result = service.depart(
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null));
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now());
assertEquals("DEPARTED", result.status());
assertNotNull(order.getDepartAt());
@@ -284,7 +313,7 @@ class DispatchPdaWorkflowServiceTest {
when(orders.lockById("100", 1L)).thenReturn(order);
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request);
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now());
assertEquals("DEPARTED", result.status());
assertTrue(result.replayed());
@@ -43,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -59,11 +60,13 @@ class DispatchServiceTest {
@Mock DispatchPdaTaskMapper pdaTasks;
@Mock LastlegOrderMapper tailOrders;
@Mock OutboundOrderMapper outboundOrders;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
private DispatchService service;
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "bolLines", bolLines);
@@ -383,9 +386,22 @@ class DispatchServiceTest {
assertEquals("ONT8,ABE4", result.route());
assertEquals("ONT8,ABE4", dispatch.getRoute());
verify(orders).updateById(dispatch);
verify(fleetBindings).requireUnbound(1L);
verify(items).findByDispatchOrder("100", 1L);
}
@Test void boundDispatchCannotEditRemoveOrCancelIndependently() {
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE");
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联"))
.when(fleetBindings).requireUnbound(1L);
assertThrows(ResponseStatusException.class, () -> service.update("PC1", null));
assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L));
assertThrows(ResponseStatusException.class, () -> service.cancel("PC1"));
verify(orders, never()).updateById((DispatchOrder) any());
org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders);
}
@Test
void platformEditRejectsAnExecutedDispatchWithoutWriting() {
DispatchOrder dispatch = new DispatchOrder();