feat(appointments): track forecast impacts and handling history

This commit is contained in:
2026-10-10 15:33:13 +08:00
parent 331f750871
commit 0ac2b849c3
18 changed files with 1323 additions and 0 deletions
@@ -3908,3 +3908,43 @@ CREATE TABLE ll_procurement_closure_item (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables;
CREATE TABLE IF NOT EXISTS ll_appointment_impact (
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
latest_json LONGTEXT NOT NULL COMMENT '最新已读取观察,未知可为JSON null',
forecast_token CHAR(64) NOT NULL COMMENT '最新观察摘要,处理时防止旧预测结案',
status VARCHAR(20) NOT NULL COMMENT '事项状态:MONITORING监测/OPEN待处理/RESOLVED已完成',
reason VARCHAR(64) NULL COMMENT '预测变化或资料待核实原因',
owner_id VARCHAR(128) NULL COMMENT '认领平台人员ID',
owner_name VARCHAR(200) NULL COMMENT '负责人姓名快照',
version INT NOT NULL COMMENT '观察及人工处理共同版本',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (container_booking_id),
UNIQUE KEY uk_appointment_impact_no (impact_no),
KEY idx_appointment_impact_work (status,updated_at),
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输';
CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
id BIGINT NOT NULL COMMENT '历史记录标识',
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
version INT NOT NULL COMMENT '记录版本',
action VARCHAR(20) NOT NULL COMMENT '操作类型:OBSERVED观察/CLAIM认领/RECORD记录/RESOLVE完成',
operator_id VARCHAR(128) NOT NULL COMMENT '操作人标识',
operator_name VARCHAR(200) NULL COMMENT '操作人姓名',
result VARCHAR(1000) NULL COMMENT '人工处理结果;观察记录为空',
next_action VARCHAR(500) NULL COMMENT '下一步处理事项',
followup_date DATE NULL COMMENT '业务跟进日期',
command_key VARCHAR(64) NULL COMMENT '人工命令幂等键;观察记录为空',
request_hash CHAR(64) NULL COMMENT '请求内容摘要',
response_json LONGTEXT NOT NULL COMMENT '该版本完整响应快照,支持精确重放',
created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_impact_version (container_booking_id,version),
UNIQUE KEY uk_appointment_impact_command (container_booking_id,operator_id,command_key),
CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id),
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name="firstleg-service",contextId="appointmentForecastClient",url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
public interface AppointmentForecastClient {
record Request(List<Long> containerBookingIds) { }
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { }
@PostMapping("/internal/firstleg/appointment-forecasts/batch")
ApiResponse<List<Forecast>> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data @TableName("ll_appointment_impact")
public class AppointmentImpact {
@TableId(type=IdType.INPUT) private String containerBookingId;
private String impactNo;
private String baselineJson;
private String latestJson;
private String forecastToken;
private String status;
private String reason;
private String ownerId;
private String ownerName;
private int version;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning/impacts")
public class AppointmentImpactController {
public record Refresh(@Min(1) int page,@Min(1) @Max(100) int size,@Size(max=128) String keyword) { }
private final AppointmentImpactService service;
private final AppointmentImpactQueryService queries;
public AppointmentImpactController(AppointmentImpactService service,AppointmentImpactQueryService queries){this.service=service;this.queries=queries;}
@GetMapping
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentImpactQueryService.Row>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String keyword) {
AppointmentPlanningController.requirePlatform(user);return ApiResponse.success(queries.page(page,size,keyword));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<AppointmentImpactQueryService.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String id,
@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) {
AppointmentPlanningController.requirePlatform(user);
boolean trips=SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream().anyMatch(a->"platform:fleet:trip:list".equals(a.getAuthority()));
return ApiResponse.success(queries.detail(id,page,size,trips));
}
@PostMapping("/refresh")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')")
public ApiResponse<List<AppointmentImpactService.View>> refresh(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody Refresh request) {
AppointmentPlanningController.requirePlatform(user);return ApiResponse.success(service.refreshPage(request.page(),request.size(),request.keyword(),user.userId().toString(),user.username()));
}
@PostMapping("/{id}/commands")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')")
public ApiResponse<AppointmentImpactService.View> handle(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String id,@Valid @RequestBody AppointmentImpactService.Command request) {
AppointmentPlanningController.requirePlatform(user);return ApiResponse.success(service.handle(id,user.userId().toString(),user.username(),request));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data @TableName("ll_appointment_impact_history")
public class AppointmentImpactHistory {
@TableId(type=IdType.INPUT) private Long id;
private String containerBookingId;
private int version;
private String action;
private String operatorId;
private String operatorName;
private String result;
private String nextAction;
private LocalDate followupDate;
private String commandKey;
private String requestHash;
private String responseJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface AppointmentImpactMapper extends BaseMapper<AppointmentImpact> {
@Select("<script>SELECT * FROM ll_appointment_impact WHERE container_booking_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<AppointmentImpact> byContainers(@Param("ids") List<String> ids);
@Select("SELECT COUNT(*) FROM ll_appointment_impact_history WHERE container_booking_id=#{id}")
long historyCount(String id);
@Select("SELECT * FROM ll_appointment_impact WHERE container_booking_id=#{id} FOR UPDATE")
AppointmentImpact lock(String id);
@Update("""
UPDATE ll_appointment_impact SET baseline_json=#{baselineJson},latest_json=#{latestJson},forecast_token=#{forecastToken},
status=#{status},reason=#{reason},owner_id=#{ownerId},owner_name=#{ownerName},version=#{version},updated_at=#{updatedAt}
WHERE container_booking_id=#{containerBookingId}
""")
int save(AppointmentImpact row);
@Select("SELECT * FROM ll_appointment_impact_history WHERE container_booking_id=#{id} AND operator_id=#{operator} AND command_key=#{key}")
AppointmentImpactHistory command(@Param("id") String id,@Param("operator") String operator,@Param("key") String key);
@Insert("""
INSERT INTO ll_appointment_impact_history
(id,container_booking_id,version,action,operator_id,operator_name,result,next_action,followup_date,command_key,request_hash,response_json,created_at)
VALUES(#{id},#{containerBookingId},#{version},#{action},#{operatorId},#{operatorName},#{result},#{nextAction},#{followupDate},#{commandKey},#{requestHash},#{responseJson},#{createdAt})
""")
int history(AppointmentImpactHistory row);
@Select("SELECT * FROM ll_appointment_impact_history WHERE container_booking_id=#{id} ORDER BY version DESC LIMIT #{size} OFFSET #{offset}")
List<AppointmentImpactHistory> historyPage(@Param("id") String id,@Param("size") int size,@Param("offset") long offset);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.appointmentplanning;
import java.util.Objects;
/** Source-local observations can prove a change, not a cross-time-zone delivery violation. */
public final class AppointmentImpactPolicy {
private AppointmentImpactPolicy() { }
public record Decision(boolean reviewRequired,String reason) { }
public static Decision compare(AppointmentForecastClient.Fact baseline,AppointmentForecastClient.Fact latest) {
if(latest==null || latest.estimatedAt()==null && latest.actualAt()==null) return new Decision(true,"FORECAST_MISSING");
if(latest.actualAt()!=null) {
return baseline==null || !Objects.equals(baseline.actualAt(),latest.actualAt())
? new Decision(true,"ACTUAL_ARRIVAL_REVIEW") : new Decision(false,"UNCHANGED");
}
if(baseline==null) return new Decision(false,"BASELINE_ESTABLISHED");
if(!Objects.equals(baseline.estimatedAt(),latest.estimatedAt()) || !Objects.equals(baseline.actualAt(),latest.actualAt()))
return new Decision(true,"FORECAST_CHANGED");
if(!Objects.equals(baseline.providerCode(),latest.providerCode()) || !Objects.equals(baseline.sourceReference(),latest.sourceReference()))
return new Decision(true,"SOURCE_CHANGED");
return new Decision(false,"UNCHANGED");
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.PageResult;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class AppointmentImpactQueryService {
public record Row(AppointmentContainer container,AppointmentImpactService.View impact) { }
public record History(int version,String action,String operatorName,String result,String nextAction,java.time.LocalDate followupDate,java.time.LocalDateTime createdAt) { }
public record Detail(AppointmentImpactService.View impact,List<AppointmentImpactRelationMapper.Cargo> cargo,
List<AppointmentImpactRelationMapper.Appointment> appointments,AppointmentImpactTransportGateway.Links transport,
PageResult<History> history) { }
private final AppointmentContainerService containers;
private final AppointmentImpactService service;
private final AppointmentImpactMapper mapper;
private final AppointmentImpactRelationMapper relations;
private final AppointmentImpactTransportGateway transport;
public AppointmentImpactQueryService(AppointmentContainerService containers,AppointmentImpactService service,AppointmentImpactMapper mapper,
AppointmentImpactRelationMapper relations,AppointmentImpactTransportGateway transport){this.containers=containers;this.service=service;this.mapper=mapper;this.relations=relations;this.transport=transport;}
public PageResult<Row> page(int page,int size,String keyword) {
var found=containers.page(page,size,keyword);
var states=found.records().isEmpty()?Map.<String,AppointmentImpact>of():mapper.byContainers(found.records().stream().map(AppointmentContainer::getContainerBookingId).toList()).stream().collect(Collectors.toMap(AppointmentImpact::getContainerBookingId,Function.identity()));
return new PageResult<>(found.records().stream().map(c->new Row(c,states.containsKey(c.getContainerBookingId())?service.view(states.get(c.getContainerBookingId())):null)).toList(),page,size,found.total(),found.pages());
}
public Detail detail(String id,int page,int size,boolean canReadTrips) {
if(page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"历史分页无效");
var state=service.detail(id);var cargo=relations.cargo(id);var appointments=relations.appointments(id);
var dispatches=canReadTrips?relations.dispatches(id):List.<AppointmentImpactTransportGateway.Dispatch>of();
if(cargo.size()>1000 || appointments.size()>1000 || dispatches.size()>1000) throw new ResponseStatusException(HttpStatus.CONFLICT,"受影响范围超过读取上限,请逐批核对");
var links=canReadTrips?transport.related(dispatches):null;
long total=mapper.historyCount(id);
var history=mapper.historyPage(id,size,(long)(page-1)*size).stream().map(h->new History(h.getVersion(),h.getAction(),h.getOperatorName(),h.getResult(),h.getNextAction(),h.getFollowupDate(),h.getCreatedAt())).toList();
return new Detail(state,cargo,appointments,links,new PageResult<>(history,page,size,total,(total+size-1)/size));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentImpactRelationMapper {
record Cargo(String customerId,Long orderId,String inboundCode,String customerName,Integer packages,String destination) { }
record Appointment(String appointmentNo,String status,String destination,LocalDateTime confirmedAt,String timezone) { }
@Select("SELECT customer_id,id AS order_id,inbound_code,customer_name,package_count AS packages,fba_warehouse_code AS destination FROM ll_order WHERE source_container_booking_id=#{id} AND order_type='FIRST_LEG' AND current_status NOT IN ('CANCELLED','COMPLETED') ORDER BY id LIMIT 1001")
List<Cargo> cargo(String id);
@Select("""
SELECT DISTINCT a.appointment_no,a.appointment_status AS status,a.fba_warehouse_code AS destination,a.appointment_at AS confirmed_at,a.timezone
FROM ll_order o JOIN ll_inbound_fba_appointment_item i ON i.order_id=o.id AND i.customer_id=o.customer_id AND i.active=1
JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
AND a.appointment_status NOT IN ('CANCELLED','FAILED') ORDER BY a.appointment_no LIMIT 1001
""")
List<Appointment> appointments(String id);
@Select("""
SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o
JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id
JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id
WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED')
AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001
""")
List<AppointmentImpactTransportGateway.Dispatch> dispatches(String id);
}
@@ -0,0 +1,140 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
@Service
public class AppointmentImpactService {
public record View(String containerBookingId,String impactNo,int version,String status,String reason,
String ownerId,String ownerName,AppointmentForecastClient.Fact baseline,AppointmentForecastClient.Fact latest,
String forecastToken,LocalDateTime updatedAt) { }
public record Command(@Min(0) int expectedVersion,@NotBlank String forecastToken,
@NotBlank @Size(max=64) String idempotencyKey,@NotBlank String action,
@Size(max=1000) String result,@Size(max=500) String nextAction,LocalDate followupDate) { }
private final AppointmentImpactMapper mapper;
private final AppointmentForecastClient forecasts;
private final AppointmentContainerService containers;
private final ObjectMapper json;
private final String token;
public AppointmentImpactService(AppointmentImpactMapper mapper,AppointmentForecastClient forecasts,
AppointmentContainerService containers,ObjectMapper json,@Value("${baiyue.security.internal-service-token:}") String token) {
this.mapper=mapper;this.forecasts=forecasts;this.containers=containers;this.json=json;this.token=token;
}
public View detail(String id) {
validId(id);var row=mapper.selectById(id);
if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"尚未建立该柜船期核对记录");
return view(row);
}
@Transactional
public View refresh(String id,String operatorId,String operatorName) {
validId(id);requireScope(id);
var row=mapper.lock(id);
// Serialize observation reads for an existing item, including an explicit missing forecast.
return observe(id,row,fetch(id),operatorId,operatorName);
}
@Transactional
public List<View> refreshPage(int page,int size,String keyword,String operatorId,String operatorName) {
var active=containers.page(page,size,keyword).records();
if(active.stream().anyMatch(AppointmentContainer::isIncompleteScope)) throw conflict("本页存在接仓范围不完整的柜次,请先逐柜核对");
if(active.isEmpty()) return List.of();
var ids=active.stream().map(AppointmentContainer::getContainerBookingId).sorted().toList();
var locked=new HashMap<String,AppointmentImpact>();
for(var id:ids) {validId(id);locked.put(id,mapper.lock(id));}
var latest=fetchBatch(ids);
return ids.stream().map(id->observe(id,locked.get(id),latest.get(id),operatorId,operatorName)).toList();
}
private View observe(String id,AppointmentImpact row,AppointmentForecastClient.Forecast forecast,String operatorId,String operatorName) {
String latest=write(forecast.latest());String fingerprint=digest(latest);
if(row!=null && fingerprint.equals(row.getForecastToken())) return view(row);
if(row!=null && older(forecast.latest(),read(row.getLatestJson(),AppointmentForecastClient.Fact.class))) return view(row);
boolean first=row==null;
if(first) {
row=new AppointmentImpact();row.setContainerBookingId(id);row.setImpactNo("AI-"+UUID.randomUUID().toString().replace("-","").substring(0,20).toUpperCase(Locale.ROOT));
row.setBaselineJson(write(forecast.previous()==null?forecast.latest():forecast.previous()));
row.setStatus("MONITORING");
}
var decision=AppointmentImpactPolicy.compare(read(row.getBaselineJson(),AppointmentForecastClient.Fact.class),forecast.latest());
row.setLatestJson(latest);row.setForecastToken(fingerprint);
if(decision.reviewRequired()) {row.setStatus("OPEN");row.setReason(decision.reason());}
else if(!"OPEN".equals(row.getStatus())) row.setReason(decision.reason());
row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());
if(first) {
try {mapper.insert(row);} catch(org.springframework.dao.DuplicateKeyException conflict) {
throw new ResponseStatusException(HttpStatus.CONFLICT,"该柜核对记录已建立,请刷新",conflict);
}
} else mapper.save(row);
append(row,"OBSERVED",operatorId,operatorName,null,null,null,null,null);
return view(row);
}
@Transactional
public View handle(String id,String operatorId,String operatorName,Command request) {
validId(id);
if(request==null || request.idempotencyKey()==null || request.idempotencyKey().isBlank()
|| !Set.of("CLAIM","RECORD","RESOLVE").contains(Objects.toString(request.action(),""))) throw bad("请选择有效处理动作和请求编号");
if(!"CLAIM".equals(request.action()) && (request.result()==null || request.result().isBlank())) throw bad("请填写实际处理结果");
String hash=digest(write(request));
var row=mapper.lock(id);
if(row==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"尚未建立该柜船期核对记录");
var replay=mapper.command(id,operatorId,request.idempotencyKey());
if(replay!=null) {
if(!hash.equals(replay.getRequestHash())) throw conflict("同一请求编号的内容不一致");
return read(replay.getResponseJson(),View.class);
}
if(row.getVersion()!=request.expectedVersion() || !Objects.equals(row.getForecastToken(),request.forecastToken())) throw conflict("事项已更新,请刷新后处理");
requireScope(id);
if(!row.getForecastToken().equals(digest(write(fetch(id).latest())))) throw conflict("船期来源已有新观察,请先刷新并核对影响");
if(!"OPEN".equals(row.getStatus())) throw conflict("当前事项不需要处理");
if(row.getOwnerId()!=null && !operatorId.equals(row.getOwnerId())) throw conflict("事项已由其他人员认领");
row.setOwnerId(operatorId);row.setOwnerName(operatorName);
if("RESOLVE".equals(request.action())) {row.setStatus("RESOLVED");row.setBaselineJson(row.getLatestJson());}
row.setVersion(row.getVersion()+1);row.setUpdatedAt(now());mapper.save(row);
append(row,request.action(),operatorId,operatorName,request.result(),request.nextAction(),request.followupDate(),request.idempotencyKey(),hash);
return view(row);
}
private void requireScope(String id) {
if(!containers.context(id,null).canFollowup()) throw conflict("柜次范围不完整,请先核对接仓点");
}
private AppointmentForecastClient.Forecast fetch(String id) {return fetchBatch(List.of(id)).get(id);}
private Map<String,AppointmentForecastClient.Forecast> fetchBatch(List<String> ids) {
try {
var response=forecasts.batch(token,new AppointmentForecastClient.Request(ids.stream().map(Long::valueOf).toList()));
if(response==null || !response.successful() || response.data()==null || response.data().size()!=ids.size()) throw new IllegalStateException();
var result=new HashMap<String,AppointmentForecastClient.Forecast>();
for(var row:response.data()) {
if(row==null || row.containerBookingId()==null || !ids.contains(row.containerBookingId().toString()) || !"SOURCE_LOCAL_UNSPECIFIED".equals(row.timeBasis())) throw new IllegalStateException();
for(var fact:Arrays.asList(row.latest(),row.previous())) if(fact!=null && (!Objects.equals(fact.containerBookingId(),row.containerBookingId()) || fact.factId()==null)) throw new IllegalStateException();
if(result.put(row.containerBookingId().toString(),row)!=null) throw new IllegalStateException();
}
return result;
} catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"船期来源暂不可用,保留原核对记录",error);}
}
private void append(AppointmentImpact row,String action,String operatorId,String operatorName,String result,String next,LocalDate date,String key,String hash) {
var history=new AppointmentImpactHistory();history.setId(IdWorker.getId());history.setContainerBookingId(row.getContainerBookingId());history.setVersion(row.getVersion());
history.setAction(action);history.setOperatorId(operatorId);history.setOperatorName(operatorName);history.setResult(result);history.setNextAction(next);history.setFollowupDate(date);
history.setCommandKey(key);history.setRequestHash(hash);history.setResponseJson(write(view(row)));history.setCreatedAt(row.getUpdatedAt());mapper.history(history);
}
View view(AppointmentImpact row) {return new View(row.getContainerBookingId(),row.getImpactNo(),row.getVersion(),row.getStatus(),row.getReason(),row.getOwnerId(),row.getOwnerName(),read(row.getBaselineJson(),AppointmentForecastClient.Fact.class),read(row.getLatestJson(),AppointmentForecastClient.Fact.class),row.getForecastToken(),row.getUpdatedAt());}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException("船期记录序列化失败",error);}}
private <T> T read(String value,Class<T> type){try{return value==null?null:json.readValue(value,type);}catch(Exception error){throw new IllegalStateException("船期记录读取失败",error);}}
private static String digest(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS);}
private static boolean older(AppointmentForecastClient.Fact incoming,AppointmentForecastClient.Fact saved) {
if(incoming==null || saved==null) return false;
var order=Comparator.comparing(AppointmentForecastClient.Fact::observedAt,Comparator.nullsFirst(Comparator.naturalOrder()))
.thenComparing(AppointmentForecastClient.Fact::factId);
return order.compare(incoming,saved)<0;
}
private static void validId(String id){try{if(id==null || Long.parseLong(id)<=0)throw new IllegalArgumentException();}catch(RuntimeException error){throw bad("柜次标识无效");}}
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.appointmentplanning;
import java.time.LocalDateTime;
import java.util.List;
/** Fleet owns trip/booking relationships; appointment planning only asks for affected commitments. */
public interface AppointmentImpactTransportGateway {
record Dispatch(String customerId,String dispatchNo) { }
record Trip(String tripNo,String status,LocalDateTime plannedDepartureAt) { }
record Booking(String tripNo,String bookingNo,String customerName,String destinationLabel,String status) { }
record Links(List<Trip> trips,List<Booking> sameTripBookings) { }
Links related(List<Dispatch> dispatches);
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.appointmentplanning.AppointmentImpactTransportGateway;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class FleetAppointmentImpactAdapter implements AppointmentImpactTransportGateway {
private final FleetAppointmentImpactMapper mapper;
public FleetAppointmentImpactAdapter(FleetAppointmentImpactMapper mapper){this.mapper=mapper;}
public Links related(List<Dispatch> dispatches) {
if(dispatches==null || dispatches.size()>1000) throw new IllegalArgumentException("受影响排车范围过大,请按柜核对");
if(dispatches.isEmpty()) return new Links(List.of(),List.of());
var trips=mapper.trips(dispatches.stream().distinct().toList());
var bookings=trips.isEmpty()?List.<Booking>of():mapper.bookings(trips.stream().map(Trip::tripNo).toList());
if(bookings.size()>1000) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"同车承诺超过读取上限,请进入车次逐项核对");
return new Links(trips,bookings);
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.appointmentplanning.AppointmentImpactTransportGateway.*;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetAppointmentImpactMapper {
@Select("""
<script>SELECT DISTINCT t.trip_no,t.status,t.planned_departure_at FROM ll_fleet_trip_dispatch d
JOIN ll_fleet_trip t ON t.id=d.trip_id WHERE t.status NOT IN ('CANCELLED','COMPLETED') AND
<foreach collection='rows' item='r' open='(' separator=' OR ' close=')'>(d.customer_id=#{r.customerId} AND d.dispatch_no=#{r.dispatchNo})</foreach>
ORDER BY t.trip_no</script>
""")
List<Trip> trips(@Param("rows") List<Dispatch> rows);
@Select("""
<script>SELECT trip_no,booking_no,customer_name,destination_label,status FROM ll_fleet_external_booking
WHERE status IN ('CONFIRMED','RECEIVED','BOUND') AND trip_no IN
<foreach collection='trips' item='t' open='(' separator=',' close=')'>#{t}</foreach>
ORDER BY trip_no,booking_no LIMIT 1001</script>
""")
List<Booking> bookings(@Param("trips") List<String> trips);
}
@@ -3523,3 +3523,43 @@ CREATE TABLE IF NOT EXISTS ll_procurement_closure_item (
PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id),
CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运';
CREATE TABLE IF NOT EXISTS ll_appointment_impact (
container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示',
impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号',
baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径',
latest_json LONGTEXT NOT NULL COMMENT '最新已读取观察,未知可为JSON null',
forecast_token CHAR(64) NOT NULL COMMENT '最新观察摘要,处理时防止旧预测结案',
status VARCHAR(20) NOT NULL COMMENT '事项状态:MONITORING监测/OPEN待处理/RESOLVED已完成',
reason VARCHAR(64) NULL COMMENT '预测变化或资料待核实原因',
owner_id VARCHAR(128) NULL COMMENT '认领平台人员ID',
owner_name VARCHAR(200) NULL COMMENT '负责人姓名快照',
version INT NOT NULL COMMENT '观察及人工处理共同版本',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (container_booking_id),
UNIQUE KEY uk_appointment_impact_no (impact_no),
KEY idx_appointment_impact_work (status,updated_at),
CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输';
CREATE TABLE IF NOT EXISTS ll_appointment_impact_history (
id BIGINT NOT NULL COMMENT '历史记录标识',
container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识',
version INT NOT NULL COMMENT '记录版本',
action VARCHAR(20) NOT NULL COMMENT '操作类型:OBSERVED观察/CLAIM认领/RECORD记录/RESOLVE完成',
operator_id VARCHAR(128) NOT NULL COMMENT '操作人标识',
operator_name VARCHAR(200) NULL COMMENT '操作人姓名',
result VARCHAR(1000) NULL COMMENT '人工处理结果;观察记录为空',
next_action VARCHAR(500) NULL COMMENT '下一步处理事项',
followup_date DATE NULL COMMENT '业务跟进日期',
command_key VARCHAR(64) NULL COMMENT '人工命令幂等键;观察记录为空',
request_hash CHAR(64) NULL COMMENT '请求内容摘要',
response_json LONGTEXT NOT NULL COMMENT '该版本完整响应快照,支持精确重放',
created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_impact_version (container_booking_id,version),
UNIQUE KEY uk_appointment_impact_command (container_booking_id,operator_id,command_key),
CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id),
CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史';
@@ -0,0 +1,643 @@
{
"openapi": "3.0.3",
"info": {
"title": "船期变化影响核对",
"version": "1.0.0"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/appointment-planning/impacts": {
"get": {
"operationId": "listAppointmentImpacts",
"description": "仅平台主体,同时要求platform:appointment:list和platform:overseas-warehouse:last-mile-order:list。按现有美国活动柜次分页,impact为空表示尚未核对。GET不读取远程船期、不创建记录。",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
},
{
"name": "keyword",
"in": "query",
"schema": {
"type": "string",
"maxLength": 128
}
}
],
"responses": {
"200": {
"description": "ApiResponse,业务成功code为字符串200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/ImpactPage"
}
}
}
}
}
},
"403": {
"description": "非平台主体或缺少权限"
}
}
}
},
"/api/platform/v1/last-leg/appointment-planning/impacts/refresh": {
"post": {
"operationId": "refreshAppointmentImpacts",
"description": "仅平台主体,同时要求platform:appointment:list和platform:overseas-warehouse:last-mile-order:list。写入另需platform:appointment:followup。显式批量核对当前页,一次Firstleg来源查询;范围不完整或来源失败保留原状态并报错。稳定柜次事项和追加观察历史,不改外部确认、订单或车次。来源时间时区未核实,不转换为UTC或推算备妥窗口。",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"page": {
"type": "integer",
"minimum": 1
},
"size": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"keyword": {
"type": "string",
"maxLength": 128
}
},
"required": [
"page",
"size"
]
}
}
}
},
"responses": {
"200": {
"description": "ApiResponse,业务成功code为字符串200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Impacts"
}
}
}
}
}
},
"403": {
"description": "非平台主体或缺少权限"
}
}
}
},
"/api/platform/v1/last-leg/appointment-planning/impacts/{id}": {
"get": {
"operationId": "readAppointmentImpact",
"description": "仅平台主体,同时要求platform:appointment:list和platform:overseas-warehouse:last-mile-order:list。直接货物和预约按客户归属关联;运输明细另外要求platform:fleet:trip:list,无权时transport为null。sameTripBookings仅提示同车运输承诺复核,不断言直接延期。明细任一范围超过1000拒绝,不静默截断。历史服务端分页,业务确认时间保持原值。",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "内部柜次关联键,不作界面业务编号"
},
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "size",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
],
"responses": {
"200": {
"description": "ApiResponse,业务成功code为字符串200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/ImpactDetail"
}
}
}
}
}
},
"403": {
"description": "非平台主体或缺少权限"
}
}
}
},
"/api/platform/v1/last-leg/appointment-planning/impacts/{id}/commands": {
"post": {
"operationId": "handleAppointmentImpact",
"description": "仅平台主体,同时要求platform:appointment:list和platform:overseas-warehouse:last-mile-order:list。写入另需platform:appointment:followup。CLAIM认领当前人;RECORD和RESOLVE必须填写实际结果。其他人已认领、事项版本或来源观察变化时拒绝;同操作者同键同体精确重放原响应。处理完成仅确认核对事项,绝不改约/撤销承运/改库存。处理后新预测变化重新打开同一事项。",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "内部柜次关联键,不作界面业务编号"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"expectedVersion": {
"type": "integer",
"minimum": 0
},
"forecastToken": {
"type": "string"
},
"idempotencyKey": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"action": {
"type": "string",
"enum": [
"CLAIM",
"RECORD",
"RESOLVE"
]
},
"result": {
"type": "string",
"nullable": true,
"maxLength": 1000
},
"nextAction": {
"type": "string",
"nullable": true,
"maxLength": 500
},
"followupDate": {
"type": "string",
"format": "date",
"nullable": true
}
},
"required": [
"expectedVersion",
"forecastToken",
"idempotencyKey",
"action"
]
}
}
}
},
"responses": {
"200": {
"description": "ApiResponse,业务成功code为字符串200",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Impact"
}
}
}
}
}
},
"403": {
"description": "非平台主体或缺少权限"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Fact": {
"type": "object",
"properties": {
"containerBookingId": {
"type": "string",
"nullable": true
},
"factId": {
"type": "string",
"nullable": true
},
"providerCode": {
"type": "string",
"nullable": true
},
"sourceReference": {
"type": "string",
"nullable": true
},
"observedAt": {
"type": "string",
"nullable": true
},
"estimatedAt": {
"type": "string",
"nullable": true
},
"actualAt": {
"type": "string",
"nullable": true
}
},
"nullable": true
},
"Impact": {
"type": "object",
"properties": {
"containerBookingId": {
"type": "string"
},
"impactNo": {
"type": "string"
},
"version": {
"type": "integer"
},
"status": {
"type": "string",
"enum": [
"MONITORING",
"OPEN",
"RESOLVED"
]
},
"reason": {
"type": "string",
"nullable": true
},
"ownerId": {
"type": "string",
"nullable": true
},
"ownerName": {
"type": "string",
"nullable": true
},
"baseline": {
"$ref": "#/components/schemas/Fact"
},
"latest": {
"$ref": "#/components/schemas/Fact"
},
"forecastToken": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"ImpactPage": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"type": "object",
"properties": {
"container": {
"type": "object",
"properties": {
"containerBookingId": {
"type": "string"
},
"containerNo": {
"type": "string",
"nullable": true
},
"orderCount": {
"type": "integer"
},
"customerCount": {
"type": "integer"
},
"packages": {
"type": "integer",
"nullable": true
},
"unknownPackageOrders": {
"type": "integer"
},
"earliestArrivalAt": {
"type": "string",
"nullable": true
},
"latestArrivalAt": {
"type": "string",
"nullable": true
},
"standardNode": {
"type": "string",
"nullable": true
},
"sourceVersion": {
"type": "string",
"nullable": true
},
"incompleteScope": {
"type": "boolean"
},
"warehouses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"warehouseId": {
"type": "string"
},
"warehouseName": {
"type": "string",
"nullable": true
},
"orderCount": {
"type": "integer"
},
"customerCount": {
"type": "integer"
}
}
}
}
}
},
"impact": {
"allOf": [
{
"$ref": "#/components/schemas/Impact"
}
],
"nullable": true
}
}
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer"
},
"size": {
"type": "integer"
},
"pages": {
"type": "integer"
}
}
},
"Impacts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Impact"
}
},
"ImpactDetail": {
"type": "object",
"properties": {
"impact": {
"$ref": "#/components/schemas/Impact"
},
"cargo": {
"type": "array",
"items": {
"type": "object",
"properties": {
"customerId": {
"type": "string"
},
"orderId": {
"type": "string"
},
"inboundCode": {
"type": "string",
"nullable": true
},
"customerName": {
"type": "string",
"nullable": true
},
"packages": {
"type": "integer",
"nullable": true
},
"destination": {
"type": "string",
"nullable": true
}
}
}
},
"appointments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"appointmentNo": {
"type": "string"
},
"status": {
"type": "string"
},
"destination": {
"type": "string",
"nullable": true
},
"confirmedAt": {
"type": "string",
"nullable": true
},
"timezone": {
"type": "string",
"nullable": true
}
}
}
},
"transport": {
"type": "object",
"properties": {
"trips": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tripNo": {
"type": "string"
},
"status": {
"type": "string"
},
"plannedDepartureAt": {
"type": "string",
"nullable": true
}
}
}
},
"sameTripBookings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tripNo": {
"type": "string"
},
"bookingNo": {
"type": "string"
},
"customerName": {
"type": "string",
"nullable": true
},
"destinationLabel": {
"type": "string",
"nullable": true
},
"status": {
"type": "string"
}
}
}
}
},
"nullable": true
},
"history": {
"type": "object",
"properties": {
"records": {
"type": "array",
"items": {
"type": "object",
"properties": {
"version": {
"type": "integer"
},
"action": {
"type": "string"
},
"operatorName": {
"type": "string",
"nullable": true
},
"result": {
"type": "string",
"nullable": true
},
"nextAction": {
"type": "string",
"nullable": true
},
"followupDate": {
"type": "string",
"format": "date",
"nullable": true
},
"createdAt": {
"type": "string"
}
}
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer"
},
"size": {
"type": "integer"
},
"pages": {
"type": "integer"
}
}
}
}
}
}
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentImpactAuthorizationTest {
@Test void readDoesNotGrantCommandsOrFleetDetailsAndCustomerCannotRead() {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var controller=c.getBean(AppointmentImpactController.class);var service=c.getBean(AppointmentImpactService.class);var queries=c.getBean(AppointmentImpactQueryService.class);
var permissions=List.of("platform:appointment:list","platform:overseas-warehouse:last-mile-order:list");
var platform=new AuthenticatedUser(1L,0L,0L,1L,"staff","PLATFORM","WEB",Set.copyOf(permissions));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList()));
controller.detail(platform,"1",1,20);verify(queries).detail("1",1,20,false);
assertThatThrownBy(()->controller.refresh(platform,new AppointmentImpactController.Refresh(1,20,null))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.handle(platform,"1",null)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(service);
var customer=new AuthenticatedUser(1L,9L,9L,1L,"customer","MERCHANT","WEB",Set.copyOf(permissions));
assertThatThrownBy(()->controller.page(customer,1,20,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
var full=new ArrayList<>(permissions);full.add("platform:appointment:followup");full.add("platform:fleet:trip:list");
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",full.stream().map(SimpleGrantedAuthority::new).toList()));
controller.refresh(platform,new AppointmentImpactController.Refresh(1,20,null));verify(service).refreshPage(1,20,null,"1","staff");
controller.detail(platform,"1",1,20);verify(queries).detail("1",1,20,true);
} finally {SecurityContextHolder.clearContext();}
}
@Configuration @EnableMethodSecurity static class Config {
@Bean AppointmentImpactService service(){return mock(AppointmentImpactService.class);}
@Bean AppointmentImpactQueryService queries(){return mock(AppointmentImpactQueryService.class);}
@Bean AppointmentImpactController controller(AppointmentImpactService service,AppointmentImpactQueryService queries){return new AppointmentImpactController(service,queries);}
}
}
@@ -0,0 +1,96 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.*;
import com.baiyue.common.core.api.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentImpactPersistenceTest {
static AppointmentForecastClient.Fact fact(long revision,int day) {
return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null);
}
static void observe(AnnotationConfigApplicationContext c,long revision,int day) {
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED"))));
}
static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
}
@Test void stableWorkItemRetainsBaselineRejectsStaleClosureAndReopensAfterResolution() {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var service=c.getBean(AppointmentImpactService.class);var db=new JdbcTemplate(c.getBean(DataSource.class));
observe(c,2,16);var initial=service.refresh("1","A","甲");
assertThat(initial.status()).isEqualTo("OPEN");assertThat(initial.baseline()).isEqualTo(fact(1,15));
assertThat(service.refresh("1","A","甲")).isEqualTo(initial);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_appointment_impact_history",Integer.class)).isEqualTo(1);
var owned=service.handle("1","A","甲",command(initial,"claim","CLAIM"));
assertThatThrownBy(()->service.handle("1","B","乙",command(owned,"other","CLAIM"))).hasMessageContaining("其他人员");
observe(c,3,17);
assertThatThrownBy(()->service.handle("1","A","甲",command(owned,"stale","RESOLVE"))).hasMessageContaining("新观察");
var changed=service.refresh("1","A","甲");assertThat(changed.baseline()).isEqualTo(initial.baseline());
assertThat(changed.impactNo()).isEqualTo(initial.impactNo());
var request=command(changed,"resolve","RESOLVE");var resolved=service.handle("1","A","甲",request);
assertThat(resolved.status()).isEqualTo("RESOLVED");
observe(c,4,18);var reopened=service.refresh("1","A","甲");
assertThat(reopened.status()).isEqualTo("OPEN");assertThat(reopened.baseline()).isEqualTo(fact(3,17));
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenThrow(new IllegalStateException("offline"));
assertThat(service.handle("1","A","甲",request)).isEqualTo(resolved);
assertThatThrownBy(()->service.refresh("1","A","甲")).hasMessageContaining("来源暂不可用");
assertThat(service.detail("1")).isEqualTo(reopened);
}
}
@Test void historyFailureRollsBackStateAndConcurrentClaimsHaveOneWinner() throws Exception {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var service=c.getBean(AppointmentImpactService.class);observe(c,2,16);var initial=service.refresh("1","A","甲");
var db=new JdbcTemplate(c.getBean(DataSource.class));
db.execute("ALTER TABLE ll_appointment_impact_history ADD CONSTRAINT fail_claim CHECK(action<>'CLAIM')");
assertThatThrownBy(()->service.handle("1","A","甲",command(initial,"retry","CLAIM"))).isInstanceOf(RuntimeException.class);
assertThat(service.detail("1")).isEqualTo(initial);
db.execute("ALTER TABLE ll_appointment_impact_history DROP CONSTRAINT fail_claim");
var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
var futures=new ArrayList<Future<Boolean>>();
for(var operator:List.of("A","B")) futures.add(executor.submit(()->{
start.await();try{service.handle("1",operator,operator,command(initial,"claim","CLAIM"));return true;}
catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}
}));
start.countDown();int winners=0;for(var future:futures) if(future.get(10,TimeUnit.SECONDS)) winners++;
assertThat(winners).isEqualTo(1);assertThat(service.detail("1").version()).isEqualTo(initial.version()+1);
} finally {executor.shutdownNow();}
}
}
@Configuration @EnableTransactionManagement static class Config {
@Bean DataSource dataSource() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
String baseline;try(var input=AppointmentImpactPersistenceTest.class.getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
try(var c=ds.getConnection();var statement=c.createStatement()) {
for(var table:List.of("ll_appointment_impact","ll_appointment_impact_history")) {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");
statement.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replaceAll(" ENGINE=InnoDB[^;]*;",";"));
}
}return ds;
}
@Bean DataSourceTransactionManager transactionManager(DataSource ds){return new DataSourceTransactionManager(ds);}
@Bean SqlSessionFactory sessionFactory(DataSource ds){var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentImpactMapper.class);return new MybatisSqlSessionFactoryBuilder().build(config);}
@Bean AppointmentImpactMapper mapper(SqlSessionFactory factory){return new SqlSessionTemplate(factory).getMapper(AppointmentImpactMapper.class);}
@Bean AppointmentForecastClient client(){return mock(AppointmentForecastClient.class);}
@Bean AppointmentContainerService containers(){var mock=mock(AppointmentContainerService.class);when(mock.context("1",null)).thenReturn(new AppointmentContainer.Context("1","CONT",null,null,true,List.of()));return mock;}
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
@Bean AppointmentImpactService service(AppointmentImpactMapper mapper,AppointmentForecastClient client,AppointmentContainerService containers,ObjectMapper json){return new AppointmentImpactService(mapper,client,containers,json,"internal");}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.*;
import com.baiyue.lastleg.fleet.business.*;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
class AppointmentImpactRelationTest {
@Test void containerLinksRespectCustomerPairsAndDistinguishSameTripExternalCommitments() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,order_type VARCHAR,current_status VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,package_count INT,fba_warehouse_code VARCHAR)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)");
s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)");
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)");
s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)");
s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)");
s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)");
s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)");
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')");
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)");
s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')");
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)");
s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(AppointmentImpactRelationMapper.class);config.addMapper(FleetAppointmentImpactMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var relations=session.getMapper(AppointmentImpactRelationMapper.class);
assertThat(relations.cargo("10")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-A");
assertThat(relations.appointments("10")).extracting(AppointmentImpactRelationMapper.Appointment::appointmentNo).containsExactly("AP-1");
var links=new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("10"));
assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1");
assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B");
}
}
}