feat(appointments): track forecast impacts and handling history
This commit is contained in:
@@ -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='船期观察及人工处理追加历史';
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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);}
|
||||
}
|
||||
}
|
||||
+96
@@ -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");}
|
||||
}
|
||||
}
|
||||
+45
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user