fix: keep fleet booking timestamps stable across persistent replay
This commit is contained in:
+12
-2
@@ -20,6 +20,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -38,15 +40,22 @@ public class FleetBookingService {
|
|||||||
private final FleetBookingIncomingGateway incoming;
|
private final FleetBookingIncomingGateway incoming;
|
||||||
private final FleetBookingQuoteContext quoteContext;
|
private final FleetBookingQuoteContext quoteContext;
|
||||||
private final FleetQuoteCandidateService quotes;
|
private final FleetQuoteCandidateService quotes;
|
||||||
|
private final Clock clock;
|
||||||
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
|
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
|
||||||
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
|
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
|
||||||
@jakarta.annotation.Resource private FleetDispatchGateway dispatches;
|
@jakarta.annotation.Resource private FleetDispatchGateway dispatches;
|
||||||
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
|
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||||
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||||
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
|
||||||
|
this(trips,bookings,reservations,history,execution,customers,json,incoming,quoteContext,quotes,Clock.systemUTC());
|
||||||
|
}
|
||||||
|
FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
|
||||||
|
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
|
||||||
|
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes,Clock clock){
|
||||||
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
|
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
|
||||||
this.quoteContext=quoteContext;this.quotes=quotes;
|
this.quoteContext=quoteContext;this.quotes=quotes;this.clock=clock;
|
||||||
}
|
}
|
||||||
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
|
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
|
||||||
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
|
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
|
||||||
@@ -305,7 +314,8 @@ public class FleetBookingService {
|
|||||||
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
|
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
|
||||||
/** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */
|
/** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */
|
||||||
private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
|
private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
|
||||||
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
|
/** DATETIME(3)持久化前固定毫秒精度,首次回执与重新读取后的幂等重放保持同一时间事实。 */
|
||||||
|
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);}
|
||||||
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
|
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
|
||||||
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
|
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
|
||||||
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
|
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.baiyue.lastleg.fleet.trip;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||||
|
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.*;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** 模拟重新从DATETIME(3)读取的实体,不让同一个内存对象掩盖首次与重放精度差异。 */
|
||||||
|
class FleetBookingServiceReplayTest {
|
||||||
|
@Test void incomingReplayKeepsPersistedMillisecondTimestampAndCreatesOnlyOneIncoming() throws Exception {
|
||||||
|
var json=new ObjectMapper().findAndRegisterModules();
|
||||||
|
var trips=mock(FleetTripMapper.class);var bookings=mock(FleetBookingMapper.class);
|
||||||
|
var history=mock(FleetTripService.class);var incoming=mock(FleetBookingIncomingGateway.class);
|
||||||
|
var trip=new FleetTripEntity();trip.setId(2L);trip.setTripNo("CT-ONE");trip.setStatus("PLANNED");trip.setWarehouseId(42L);
|
||||||
|
trip.setWarehouseName("TEST");trip.setRecordVersion(5L);trip.setPlannedDepartureAt(LocalDateTime.parse("2026-10-23T12:00:00"));
|
||||||
|
when(trips.lockByNo("CT-ONE")).thenReturn(trip);
|
||||||
|
var row=new FleetBookingEntity();row.setId(1L);row.setTripId(2L);row.setTripNo("CT-ONE");row.setBookingNo("FB-ONE");
|
||||||
|
row.setCustomerId("101");row.setStatus("CONFIRMED");row.setRecordVersion(7L);row.setDestinationKey("FBA:ONT8");
|
||||||
|
row.setUpdatedAt(LocalDateTime.parse("2026-10-23T09:00:00"));
|
||||||
|
row.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","TEST",1,1,BigDecimal.ONE,BigDecimal.ONE,
|
||||||
|
"PALLET",BigDecimal.TEN,"USD",null,null,null)));
|
||||||
|
var persisted=new AtomicReference<>(row);
|
||||||
|
when(bookings.lock(2L,"FB-ONE")).thenAnswer(call->json.convertValue(persisted.get(),FleetBookingEntity.class));
|
||||||
|
when(bookings.updateById(any(FleetBookingEntity.class))).thenAnswer(call->{
|
||||||
|
var stored=json.convertValue(call.getArgument(0),FleetBookingEntity.class);
|
||||||
|
stored.setUpdatedAt(stored.getUpdatedAt().truncatedTo(ChronoUnit.MILLIS));persisted.set(stored);return 1;
|
||||||
|
});
|
||||||
|
doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;})
|
||||||
|
.when(history).recordExecution(eq(trip),eq(5L),eq("BOOKING_INCOMING"),anyString(),eq("operator"));
|
||||||
|
when(incoming.create(eq("operator"),any())).thenReturn(new FleetBookingIncomingGateway.Evidence(
|
||||||
|
"ZY-ONE","RK-ONE","42","FBA:ONT8","WAIT_PDA_RECEIVING",null,List.of(),0));
|
||||||
|
var service=new FleetBookingService(trips,bookings,new FleetBookingReservations(bookings,json),history,
|
||||||
|
mock(FleetTripExecutionService.class),mock(FleetCustomerDirectory.class),json,incoming,
|
||||||
|
mock(FleetBookingQuoteContext.class),mock(FleetQuoteCandidateService.class),
|
||||||
|
Clock.fixed(Instant.parse("2026-10-23T10:00:00.123456789Z"),ZoneOffset.UTC));
|
||||||
|
var command=new FleetBookingModels.StartIncoming(5L,7L,Instant.parse("2026-10-23T11:00:00Z"),"TEST",null,null,null,null,"test-incoming-key");
|
||||||
|
var first=service.startIncoming("CT-ONE","FB-ONE","operator",command);
|
||||||
|
var replay=service.startIncoming("CT-ONE","FB-ONE","operator",command);
|
||||||
|
assertFalse(first.replayed());assertTrue(replay.replayed());
|
||||||
|
assertEquals(Instant.parse("2026-10-23T10:00:00.123Z"),first.booking().updatedAt());
|
||||||
|
assertEquals(first.booking(),replay.booking());assertEquals(first.tripRecordVersion(),replay.tripRecordVersion());
|
||||||
|
verify(incoming,times(1)).create(eq("operator"),any());verify(bookings,times(1)).updateById(any(FleetBookingEntity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user