feat(appointments): track forecast impacts and handling history
This commit is contained in:
+17
@@ -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);
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+44
@@ -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));
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
+40
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
+140
@@ -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);}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user