fix(calendar): include dispatch container numbers in calendar

This commit is contained in:
2026-10-21 11:38:18 +08:00
parent 6b2df287bd
commit 9fb39604c6
6 changed files with 149 additions and 8 deletions
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
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.*;
@@ -18,27 +19,29 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> incompleteReasons) { }
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripMapper trips;
private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses;
private final ObjectMapper json;
private final String token;
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json,
/** 装配日历持久化入口与真实仓库元数据提供者。 */
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
}
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
@Transactional(readOnly=true)
/** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,12 +67,35 @@ public class FleetTripCalendarService {
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry);
}
return new Result(List.copyOf(records),List.copyOf(unresolved));
}
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
}
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
if (ids.isEmpty()) return Map.of();
try {
@@ -85,6 +111,10 @@ public class FleetTripCalendarService {
return result;
} catch (RuntimeException failure) { throw unavailable(); }
}
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
}
@@ -19,6 +19,18 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""")
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
</script>
""")
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -359,7 +359,7 @@ components:
schemas:
TripCalendarEntry:
type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
properties:
sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -375,6 +375,11 @@ components:
departedAt: {type: string, format: date-time, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
containerNumbers:
type: array
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string}
uniqueItems: true
incompleteReasons:
type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -62,4 +62,24 @@ class FleetTripCalendarMapperTest {
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001);
}
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)",
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}",
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var repository=new FleetTripCalendarRepository(mapper);
assertThat(repository.sourceSnapshots(List.of())).isEmpty();
var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED"));
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
}
}
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripCalendarServiceTest {
final FleetTripMapper trips=mock(FleetTripMapper.class);
final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class);
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
@@ -107,4 +107,51 @@ class FleetTripCalendarServiceTest {
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
}
/** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */
@Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() {
var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0));
when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L)));
when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"),
new FleetTripCalendarRepository.Source("PC-B","{}"),
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}")));
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
var result=service.calendar(from,to);
assertThat(result.records()).hasSize(2);
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
assertThat(result.records().get(1).containerNumbers()).isEmpty();
assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z"));
assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles");
assertThat(result.unresolvedRecords()).isEmpty();
verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips);
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
verifyNoMoreInteractions(warehouses);
}
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */
@Test void keepsKnownContainersOnAnUnresolvedOrigin() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}")));
metadata(List.of(warehouse(1,"US",null)),List.of());
var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
});
}
/** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) {
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("柜号资料暂不可用");
}
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}")));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
verifyNoInteractions(warehouses);
}
}