From 0ac2b849c32539fab5289a7161f0a0a8cf33ba80 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 15:33:13 +0800 Subject: [PATCH] feat(appointments): track forecast impacts and handling history --- deploy/sql/海外区域统一增量-MySQL8.sql | 40 ++ .../AppointmentForecastClient.java | 17 + .../AppointmentImpact.java | 20 + .../AppointmentImpactController.java | 44 ++ .../AppointmentImpactHistory.java | 23 + .../AppointmentImpactMapper.java | 31 + .../AppointmentImpactPolicy.java | 22 + .../AppointmentImpactQueryService.java | 40 ++ .../AppointmentImpactRelationMapper.java | 29 + .../AppointmentImpactService.java | 140 ++++ .../AppointmentImpactTransportGateway.java | 13 + .../FleetAppointmentImpactAdapter.java | 19 + .../FleetAppointmentImpactMapper.java | 23 + .../src/main/resources/db/lastleg.sql | 40 ++ .../openapi/appointment-impact-v1.yaml | 643 ++++++++++++++++++ .../AppointmentImpactAuthorizationTest.java | 38 ++ .../AppointmentImpactPersistenceTest.java | 96 +++ .../AppointmentImpactRelationTest.java | 45 ++ 18 files changed, 1323 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactTransportGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java create mode 100644 lastleg-service/src/main/resources/openapi/appointment-impact-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactAuthorizationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a9f0af766..8ec8d2b43 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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='船期观察及人工处理追加历史'; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java new file mode 100644 index 000000000..75727828e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java @@ -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 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> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpact.java new file mode 100644 index 000000000..55d30f76f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpact.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactController.java new file mode 100644 index 000000000..6623039ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactController.java @@ -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> 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 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> 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 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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactHistory.java new file mode 100644 index 000000000..a00f5b0f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactHistory.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactMapper.java new file mode 100644 index 000000000..e622c8803 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactMapper.java @@ -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 { + @Select("") + List byContainers(@Param("ids") List 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 historyPage(@Param("id") String id,@Param("size") int size,@Param("offset") long offset); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPolicy.java new file mode 100644 index 000000000..624c193ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPolicy.java @@ -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"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactQueryService.java new file mode 100644 index 000000000..f94c929b5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactQueryService.java @@ -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 cargo, + List appointments,AppointmentImpactTransportGateway.Links transport, + PageResult 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 page(int page,int size,String keyword) { + var found=containers.page(page,size,keyword); + var states=found.records().isEmpty()?Map.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.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)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java new file mode 100644 index 000000000..1072fb67c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java @@ -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(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 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 dispatches(String id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactService.java new file mode 100644 index 000000000..da0825fd7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactService.java @@ -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 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(); + 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 fetchBatch(List 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(); + 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 read(String value,Class 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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactTransportGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactTransportGateway.java new file mode 100644 index 000000000..0e757f1ff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactTransportGateway.java @@ -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 trips,List sameTripBookings) { } + Links related(List dispatches); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactAdapter.java new file mode 100644 index 000000000..a3df274d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactAdapter.java @@ -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 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.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); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java new file mode 100644 index 000000000..fe2092ae3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java @@ -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(""" + + """) + List trips(@Param("rows") List rows); + @Select(""" + + """) + List bookings(@Param("trips") List trips); +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 4af712887..566ef2d3a 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='船期观察及人工处理追加历史'; diff --git a/lastleg-service/src/main/resources/openapi/appointment-impact-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-impact-v1.yaml new file mode 100644 index 000000000..ca47f835f --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-impact-v1.yaml @@ -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" + } + } + } + } + } + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactAuthorizationTest.java new file mode 100644 index 000000000..5dd2f0973 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactAuthorizationTest.java @@ -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);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java new file mode 100644 index 000000000..17ae1e105 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java @@ -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>(); + 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");} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java new file mode 100644 index 000000000..ad8e23f83 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java @@ -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"); + } + } +}