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
@@ -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);
}