feat(appointments): unify pre-inbound planning and confirmed FBA bookings

This commit is contained in:
2026-10-10 10:38:06 +08:00
parent 24f5ccf7ff
commit c4c1bddeec
54 changed files with 1623 additions and 744 deletions
@@ -160,7 +160,7 @@ public class InboundPlanController {
@PathVariable String appointmentNo,
@Valid @RequestBody UpdateFbaAppointmentRequest request) {
requireAppointmentPlatform(user);
return ApiResponse.success(appointmentService.update(appointmentNo, request));
return ApiResponse.success(appointmentService.update(String.valueOf(user.userId()), appointmentNo, request));
}
@DeleteMapping("/fba-appointments/{appointmentNo}")
@@ -169,12 +169,12 @@ public class InboundPlanController {
public ApiResponse<Void> deleteFbaAppointment(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo, @RequestParam int version) {
requireAppointmentPlatform(user);
appointmentService.delete(appointmentNo, version);
appointmentService.delete(String.valueOf(user.userId()), appointmentNo, version);
return ApiResponse.success(null);
}
private void requireAppointmentPlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (user == null || user.userId() == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "预约由平台统一管理");
}
@@ -9,12 +9,10 @@ import java.time.LocalDateTime;
import java.util.List;
public record BatchFbaAppointmentRequest(
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
@NotBlank @Size(max = 64) String appointmentNo,
@NotNull LocalDateTime appointmentAt,
@NotNull LocalDateTime expectedLoadingAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark,
LocalDateTime appointmentEndAt
@NotEmpty @Size(max = 100) List<@NotNull Long> lineIds,
@NotBlank String originWarehouseId,
@NotBlank @Size(max=64) String fbaWarehouseCode,
@NotNull @jakarta.validation.Valid FbaAppointmentDetails details,
@NotBlank @Size(max = 64) String idempotencyKey
) {
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/** Planned windows and the receiving party's confirmation are separate facts. */
public record FbaAppointmentDetails(
java.time.LocalDate plannedDate,
LocalDateTime plannedAt, LocalDateTime plannedEndAt,
@Size(max=128) String confirmationNo,
LocalDateTime appointmentAt, LocalDateTime appointmentEndAt,
@NotBlank @Size(max=32) String appointmentStatus,
@Size(max=1000) String appointmentRemark,
@Size(max=6) String startOffset, @Size(max=6) String endOffset
) { }
@@ -6,20 +6,27 @@ import java.util.List;
public record FbaAppointmentResponse(
Long id,
String appointmentNo,
String confirmationNo,
java.time.LocalDate plannedDate,
LocalDateTime plannedAt,
LocalDateTime plannedEndAt,
LocalDateTime appointmentAt,
LocalDateTime expectedLoadingAt,
LocalDateTime appointmentEndAt,
java.time.Instant appointmentStartUtc,
java.time.Instant appointmentEndUtc,
String timezone,
String appointmentStatus,
String appointmentRemark,
String originWarehouseId,
String fbaWarehouseCode,
int selectedCount,
String createdBy,
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<Item> items,
LocalDateTime appointmentEndAt,
Integer version
) {
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
String fbaWarehouseCode, String customerId) {
public record Item(Long id, String sourceKey, String sourceVersion, Long orderId, String inboundCode,
String fbaWarehouseCode, String customerId, Integer packageCount) {
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.inboundplan.dto;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.List;
public record SourceFbaAppointmentRequest(
@NotEmpty @Size(max=100) List<@NotNull SourceRef> sources,
@NotBlank String originWarehouseId,
@NotBlank @Size(max=64) String fbaWarehouseCode,
@NotNull @Valid FbaAppointmentDetails details,
@NotBlank @Size(max=64) String idempotencyKey
) { }
@@ -7,11 +7,8 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
public record UpdateFbaAppointmentRequest(
@NotNull LocalDateTime appointmentAt,
@NotNull LocalDateTime expectedLoadingAt,
@NotBlank @Size(max = 32) String appointmentStatus,
@Size(max = 1000) String appointmentRemark,
LocalDateTime appointmentEndAt,
@NotNull @jakarta.validation.constraints.Min(0) Integer version
@NotNull @jakarta.validation.Valid FbaAppointmentDetails details,
@NotNull @jakarta.validation.constraints.Min(0) Integer version,
@NotBlank @Size(max = 64) String idempotencyKey
) {
}
@@ -13,11 +13,26 @@ public class InboundFbaAppointment {
@TableId(type = IdType.INPUT)
private Long id;
private String appointmentNo;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String confirmationNo;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private java.time.LocalDate plannedDate;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime plannedAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime plannedEndAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime appointmentAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime appointmentEndAt;
private Integer version;
private LocalDateTime expectedLoadingAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime appointmentStartUtc;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private LocalDateTime appointmentEndUtc;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String timezone;
private String originWarehouseId;
private String appointmentStatus;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String appointmentRemark;
@@ -14,9 +14,15 @@ public class InboundFbaAppointmentItem {
private Long id;
private Long appointmentId;
private String customerId;
private Long planId;
private Long lineId;
private String sourceKey;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String sourceVersion;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private Integer packageCount;
private Boolean active;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private Long orderId;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String inboundCode;
private String fbaWarehouseCode;
private LocalDateTime createdAt;
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.inboundplan.mapper;
import org.apache.ibatis.annotations.*;
@Mapper
public interface FbaAppointmentCommandMapper {
record Command(String requestHash,String responseJson) { }
@Select("SELECT request_hash AS requestHash,response_json AS responseJson FROM ll_fba_appointment_command WHERE command_key=#{key}")
Command find(@Param("key") String key);
@Insert("INSERT INTO ll_fba_appointment_command(command_key,request_hash,operation,operator_id,created_at) VALUES(#{key},#{hash},#{operation},#{operator},#{createdAt})")
int begin(@Param("key") String key,@Param("hash") String hash,@Param("operation") String operation,@Param("operator") String operator,@Param("createdAt") java.time.LocalDateTime createdAt);
@Update("UPDATE ll_fba_appointment_command SET appointment_no=#{appointmentNo},response_json=#{response} WHERE command_key=#{key} AND response_json IS NULL")
int finish(@Param("key") String key,@Param("appointmentNo") String appointmentNo,@Param("response") String response);
}
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.mapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -9,21 +10,33 @@ import java.util.List;
@Mapper
public interface InboundFbaAppointmentQueryMapper {
@Select("<script>SELECT id FROM ll_order WHERE id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY id FOR UPDATE</script>")
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
List<Long> lockOrders(@Param("ids") List<Long> ids);
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1 " +
"<if test='excludedId != null'>AND appointment_id&lt;&gt;#{excludedId} </if>AND (source_key IN " +
"<foreach collection='keys' item='key' open='(' separator=',' close=')'>#{key}</foreach> " +
"<if test='orderIds != null and !orderIds.isEmpty()'>OR order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if>)</script>")
long countActiveSources(@Param("keys") List<String> keys,@Param("orderIds") List<Long> orderIds,@Param("excludedId") Long excludedId);
@Select("<script>SELECT COUNT(*) FROM ll_order o "+com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.TRANSFER_JOIN+
" WHERE o.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> AND ("+
com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.SHIPMENT_MISSING+") </script>")
long countMissingShipmentReferences(@Param("ids") List<Long> ids);
@Select("<script>" +
"SELECT l.id AS lineId,l.plan_id AS planId,l.order_id AS orderId,l.inbound_code AS inboundCode," +
"l.fba_warehouse_code AS fbaWarehouseCode,p.customer_id AS customerId " +
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
"WHERE l.id IN " +
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"ORDER BY l.id FOR UPDATE" +
"ORDER BY l.id" +
"</script>")
List<FbaAppointmentLineSnapshot> lockLines(@Param("lineIds") List<Long> lineIds);
List<FbaAppointmentLineSnapshot> selectedLines(@Param("lineIds") List<Long> lineIds);
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i " +
"JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id " +
"WHERE a.appointment_status NOT IN ('CANCELLED','FAILED') " +
"<if test='excludedId != null'>AND a.id&lt;&gt;#{excludedId} </if>AND i.line_id IN " +
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
@Select("<script>SELECT COUNT(*) FROM ll_inbound_fba_appointment_item i WHERE i.active=1 " +
"<if test='excludedId != null'>AND i.appointment_id&lt;&gt;#{excludedId} </if>AND i.order_id IN " +
"(SELECT l.order_id FROM ll_inbound_plan_line l WHERE l.id IN <foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>)</script>")
long countActiveAppointments(@Param("lineIds") List<Long> lineIds, @Param("excludedId") Long excludedId);
@Select("""
@@ -24,9 +24,9 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
"WHERE ai.plan_id=p.id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
"WHERE ai.order_id=p.source_order_id AND ai.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
"WHERE ai.plan_id=p.id)</if>" +
"WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id AND ai.active=1)</if>" +
"<if test='q.createdFrom != null'> AND p.created_at &gt;=#{q.createdFrom}</if>" +
"<if test='q.createdTo != null'> AND p.created_at &lt;=#{q.createdTo}</if>";
@@ -35,7 +35,7 @@ public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
"l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," +
"COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," +
"COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " +
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.plan_id=p.id " +
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.order_id=p.source_order_id AND ai.customer_id=p.customer_id " +
"ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" +
PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<InboundPlanSummary> pageRows(@Param("customerId") String customerId,
@@ -20,10 +20,10 @@ public interface InboundPlanQueryMapper {
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
" JOIN ll_order o ON o.id=l.order_id AND o.customer_id=p.customer_id" +
"<if test='customerId != null and customerId != \"\"'> AND p.customer_id=#{customerId} AND o.customer_id=#{customerId}</if>" +
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" +
" LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.order_id=l.order_id AND ai.customer_id=p.customer_id" +
" AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" +
" JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" +
" WHERE ai2.line_id=l.id AND ai2.customer_id=p.customer_id" +
" WHERE ai2.order_id=l.order_id AND ai2.customer_id=p.customer_id AND ai2.fba_warehouse_code=l.fba_warehouse_code" +
" ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" +
" LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id" +
" WHERE 1=1" +
@@ -35,7 +35,7 @@ public interface InboundPlanQueryMapper {
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
"<if test='q.warehouse != null and q.warehouse != \"\"'> AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))</if>" +
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')</if>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND ai.id IS NULL</when>" +
"<choose><when test='q.appointmentStatus == \"UNAPPOINTED\"'> AND (ai.id IS NULL OR ai.active=0)</when>" +
"<when test='q.appointmentStatus != null and q.appointmentStatus != \"\"'> AND a.appointment_status=#{q.appointmentStatus}</when></choose>" +
"<choose><when test='q.dispatchStatus == \"SCHEDULED\"'> AND " + SCHEDULED_DISPATCH + "</when>" +
"<when test='q.dispatchStatus == \"UNSCHEDULED\"'> AND NOT " + SCHEDULED_DISPATCH + "</when></choose>" +
@@ -57,7 +57,7 @@ public interface InboundPlanQueryMapper {
"l.planned_pallet_count palletCount,p.assigned_flag assigned,p.merge_required mergeAllowed," +
"p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," +
"a.appointment_status appointmentStatus,a.appointment_at appointmentAt," +
"a.expected_loading_at expectedLoadingAt," +
"NULL expectedLoadingAt," +
"CASE WHEN " + SCHEDULED_DISPATCH +
" THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" +
FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
@@ -2,274 +2,230 @@ package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.entity.*;
import com.baiyue.lastleg.inboundplan.mapper.*;
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.common.core.api.PageResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@Service
public class InboundFbaAppointmentService {
@Resource private InboundFbaAppointmentMapper appointmentMapper;
@Resource private InboundFbaAppointmentItemMapper itemMapper;
@Resource private InboundFbaAppointmentQueryMapper queryMapper;
@Resource private FbaAppointmentCommandMapper commands;
@Resource private AppointmentSourceService sources;
@Resource private AppointmentBatchPreviewMapper flags;
@Resource private ObjectMapper json;
/** A bounded calendar read uses two batch queries, never one detail query per appointment. */
@Transactional(readOnly = true)
public List<FbaAppointmentResponse> calendar(LocalDate from, LocalDate to) {
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为 1 至 42 天");
}
List<InboundFbaAppointment> appointments = appointmentMapper.selectList(
new LambdaQueryWrapper<InboundFbaAppointment>()
.ge(InboundFbaAppointment::getAppointmentAt, from.atStartOfDay())
.lt(InboundFbaAppointment::getAppointmentAt, to.atStartOfDay())
.orderByAsc(InboundFbaAppointment::getAppointmentAt, InboundFbaAppointment::getId));
if (appointments.isEmpty()) return List.of();
var groupedItems = itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
.in(InboundFbaAppointmentItem::getAppointmentId,
appointments.stream().map(InboundFbaAppointment::getId).toList())
.orderByAsc(InboundFbaAppointmentItem::getLineId))
.stream().collect(java.util.stream.Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId));
return appointments.stream().map(appointment -> response(appointment,
groupedItems.getOrDefault(appointment.getId(), List.of()))).toList();
@Transactional(readOnly=true)
public List<FbaAppointmentResponse> calendar(LocalDate from,LocalDate to) {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42) throw bad("日历查询范围必须为1至42天");
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"))
.apply("COALESCE(appointment_at,planned_at,planned_date)>={0} AND COALESCE(appointment_at,planned_at,planned_date)<{1}",from.atStartOfDay(),to.atStartOfDay())
.orderByAsc(InboundFbaAppointment::getId);
return responses(appointmentMapper.selectList(query));
}
@Transactional(readOnly=true)
public PageResult<FbaAppointmentResponse> unscheduled(int page,int size) {
if(page<1 || size<1 || size>100) throw bad("分页参数无效");
var query=new LambdaQueryWrapper<InboundFbaAppointment>()
.isNull(InboundFbaAppointment::getAppointmentAt).isNull(InboundFbaAppointment::getPlannedAt)
.isNull(InboundFbaAppointment::getPlannedDate)
.notIn(InboundFbaAppointment::getAppointmentStatus,List.of("CANCELLED","FAILED"));
long total=appointmentMapper.selectCount(query);
var rows=appointmentMapper.selectList(query.orderByDesc(InboundFbaAppointment::getCreatedAt)
.last("LIMIT "+size+" OFFSET "+(long)(page-1)*size));
return new PageResult<>(responses(rows),page,size,total,(total+size-1)/size);
}
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse batchCreate(String operatorId,
BatchFbaAppointmentRequest request) {
List<Long> lineIds = List.copyOf(request.lineIds());
if (new HashSet<>(lineIds).size() != lineIds.size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates");
}
String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required");
String status = normalizeStatus(request.appointmentStatus());
String remark = trimToNull(request.appointmentRemark());
validateWindow(request.appointmentAt(), request.appointmentEndAt());
InboundFbaAppointment existing = find(appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
}
List<FbaAppointmentLineSnapshot> lines = queryMapper.lockLines(lineIds);
if (lines.size() != lineIds.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"所选入仓明细不存在,请刷新后重试");
}
String fbaWarehouseCode = commonFbaWarehouse(lines);
// The row locks serialize concurrent submissions selecting the same inbound lines.
existing = find(appointmentNo);
if (existing != null) {
return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
}
if (queryMapper.countActiveAppointments(lineIds, null) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有有效预约,请编辑原预约或先取消");
}
LocalDateTime now = LocalDateTime.now();
InboundFbaAppointment appointment = new InboundFbaAppointment();
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse batchCreate(String operatorId,BatchFbaAppointmentRequest request) {
return command("CREATE_FROM_LINES",operatorId,request.idempotencyKey(),request,()->{
var ids=List.copyOf(request.lineIds());
if(ids.isEmpty() || ids.size()>100 || new HashSet<>(ids).size()!=ids.size()) throw bad("请选择不重复的入仓明细,每次最多100条");
var lines=queryMapper.selectedLines(ids);
if(lines.size()!=ids.size()) throw missing();
var refs=sources.canonicalOrders(lines.stream().map(line->new AppointmentExternalSourceGateway.OrderRef(line.customerId(),line.orderId())).distinct().toList());
return create(operatorId,refs,request.originWarehouseId(),request.fbaWarehouseCode(),request.details());
});
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) {
return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details()));
}
private FbaAppointmentResponse create(String operator,List<AppointmentCoverageService.SourceRef> refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) {
var resolved=resolveForWrite(refs,null);
var group=group(resolved);
if(clean(expectedOrigin)==null || clean(expectedDestination)==null) throw bad("请选择接仓点和目的仓后重新核对货物");
if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物");
var appointment=new InboundFbaAppointment();
appointment.setId(IdWorker.getId());
appointment.setAppointmentNo(appointmentNo);
appointment.setAppointmentAt(request.appointmentAt());
appointment.setAppointmentEndAt(request.appointmentEndAt());
appointment.setVersion(0);
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
appointment.setAppointmentStatus(status);
appointment.setAppointmentRemark(remark);
appointment.setFbaWarehouseCode(fbaWarehouseCode);
appointment.setSelectedCount(lines.size());
appointment.setCreatedBy(trimToNull(operatorId));
appointment.setCreatedAt(now);
appointment.setUpdatedAt(now);
try {
appointmentMapper.insert(appointment);
} catch (DuplicateKeyException exception) {
InboundFbaAppointment concurrent = find(appointmentNo);
if (concurrent != null) {
return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark, request.appointmentEndAt());
}
throw exception;
}
for (FbaAppointmentLineSnapshot line : lines) {
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
item.setId(IdWorker.getId());
item.setAppointmentId(appointment.getId());
item.setCustomerId(line.customerId());
item.setPlanId(line.planId());
item.setLineId(line.lineId());
item.setOrderId(line.orderId());
item.setInboundCode(line.inboundCode());
item.setFbaWarehouseCode(line.fbaWarehouseCode());
item.setCreatedAt(now);
itemMapper.insert(item);
}
return response(appointment, items(appointment.getId()));
appointment.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT));
appointment.setOriginWarehouseId(group.originWarehouseId());
appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode());
appointment.setSelectedCount(resolved.size());appointment.setVersion(0);
appointment.setCreatedBy(operator);appointment.setCreatedAt(now());appointment.setUpdatedAt(appointment.getCreatedAt());
apply(appointment,details,resolved);
if(Set.of("FAILED","CANCELLED").contains(appointment.getAppointmentStatus())) throw bad("新安排请先保存待确认或登记已取得的正式确认");
try {appointmentMapper.insert(appointment);}
catch(DuplicateKeyException duplicate){throw conflict("该目的仓的外部约号已有记录,请继续原预约");}
writeItems(appointment,resolved,List.of());
return response(appointment,items(appointment.getId()));
}
@Transactional(readOnly = true)
@Transactional(readOnly=true)
public FbaAppointmentResponse detail(String appointmentNo) {
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
if (appointment == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
}
return response(appointment, items(appointment.getId()));
var appointment=find(appointmentNo,false);if(appointment==null) throw missing();
return response(appointment,items(appointment.getId()));
}
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse update(String appointmentNo,
UpdateFbaAppointmentRequest request) {
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "Appointment number is required"));
if (appointment == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found");
}
String status = normalizeStatus(request.appointmentStatus());
validateWindow(request.appointmentAt(), request.appointmentEndAt());
if (request.version() == null || !Objects.equals(appointment.getVersion(), request.version())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
}
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
if (!lineIds.isEmpty()) {
queryMapper.lockLines(lineIds);
if (!Set.of("FAILED", "CANCELLED").contains(status)
&& queryMapper.countActiveAppointments(lineIds, appointment.getId()) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "所选货物已有其他有效预约,不能恢复当前预约");
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) {
return command("UPDATE:"+appointmentNo,operatorId,request.idempotencyKey(),request,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
if(!Objects.equals(appointment.getVersion(),request.version())) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("已关联有效排车,请先核对并处理运输安排后再改约");
var current=items(appointment.getId());
String status=status(request.details().appointmentStatus());
if(Set.of("FAILED","CANCELLED").contains(status)) {
if(clean(request.details().appointmentRemark())==null) throw bad("取消或未约成时请填写原因");
// Keep the last planned and confirmed times as historical facts.
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(request.details().appointmentRemark().trim());
current.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
} else {
var refs=current.stream().map(item->new AppointmentCoverageService.SourceRef(item.getCustomerId(),item.getSourceKey(),"FBA:"+appointment.getFbaWarehouseCode())).toList();
var resolved=resolveForWrite(refs,appointment.getId());
if(!Objects.equals(group(resolved).originWarehouseId(),appointment.getOriginWarehouseId())) throw conflict("接仓点已变化,请重新核对安排");
if("SUCCESS".equals(appointment.getAppointmentStatus()) && !"SUCCESS".equals(status)) throw bad("已确认预约不能直接退回待确认,请记录取消原因后重新安排");
apply(appointment,request.details(),resolved);
writeItems(appointment,resolved,current);
}
appointment.setVersion(request.version()+1);appointment.setUpdatedAt(now());
if(appointmentMapper.update(appointment,new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getId,appointment.getId()).eq(InboundFbaAppointment::getVersion,request.version()))!=1) throw conflict("预约版本已变化");
return response(appointment,items(appointment.getId()));
});
}
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public void delete(String operatorId,String appointmentNo,int version) {
command("CANCEL:"+appointmentNo,operatorId,"CANCEL:"+appointmentNo+":"+version,version,()->{
var appointment=find(appointmentNo,true);if(appointment==null) throw missing();
if(!Objects.equals(appointment.getVersion(),version)) throw conflict("预约已更新,请重新读取后操作");
if(queryMapper.countActiveDispatches(appointment.getId())>0) throw conflict("预约货物已关联排车,不允许取消,请先处理关联排车");
appointment.setAppointmentStatus("CANCELLED");appointment.setAppointmentRemark("操作员取消预约安排");
appointment.setVersion(version+1);appointment.setUpdatedAt(now());appointmentMapper.updateById(appointment);
var rows=items(appointment.getId());rows.forEach(item->{item.setActive(false);itemMapper.updateById(item);});
return response(appointment,rows);
});
}
private List<AppointmentSourceService.Source> resolveForWrite(List<AppointmentCoverageService.SourceRef> refs,Long excludedId) {
sources.lockForWrite(refs);
var resolved=sources.resolveBatch(refs);
var ids=resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().sorted().toList();
if(!ids.isEmpty()) {
if(queryMapper.lockOrders(ids).size()!=ids.size()) throw conflict("货源已变化,请重新查询");
// Re-read after acquiring row locks; a pre-lock snapshot must not authorize a write.
resolved=sources.resolveBatch(refs);
var locked=Set.copyOf(ids);
if(resolved.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).anyMatch(id->!locked.contains(id))) throw conflict("来货关联已变化,请重新提交安排");
var states=flags.flags(ids);
if(states.size()!=ids.size() || states.stream().anyMatch(s->s.detained() || s.mixedDestinations())) throw conflict("货物暂扣或含多个目的地,请先核对");
}
appointment.setAppointmentAt(request.appointmentAt());
appointment.setExpectedLoadingAt(request.expectedLoadingAt());
appointment.setAppointmentStatus(status);
appointment.setAppointmentEndAt(request.appointmentEndAt());
appointment.setVersion(request.version() + 1);
appointment.setAppointmentRemark(trimToNull(request.appointmentRemark()));
appointment.setUpdatedAt(LocalDateTime.now());
if (appointmentMapper.update(appointment, new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getId, appointment.getId())
.eq(InboundFbaAppointment::getVersion, request.version())) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请重新打开后再保存");
}
return response(appointment, items(appointment.getId()));
if(resolved.stream().anyMatch(s->!s.canFollowup())) throw conflict("所选来源不再具备安排资格");
if(queryMapper.countActiveSources(resolved.stream().map(AppointmentSourceService.Source::sourceKey).toList(),ids,excludedId)>0) throw conflict("所选货物已有有效预约安排,请继续原单");
return resolved;
}
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public void delete(String appointmentNo, int version) {
InboundFbaAppointment appointment = find(requiredTrimmed(appointmentNo, "预约编号不能为空"));
if (appointment == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "预约不存在");
List<Long> lineIds = items(appointment.getId()).stream().map(InboundFbaAppointmentItem::getLineId).sorted().toList();
if (!lineIds.isEmpty()) queryMapper.lockLines(lineIds);
if (queryMapper.countActiveDispatches(appointment.getId()) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约货物已关联排车,不允许删除,请先处理关联排车");
}
if (appointmentMapper.delete(new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getId, appointment.getId())
.eq(InboundFbaAppointment::getVersion, version)) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "预约已被其他人修改,请刷新后重试");
}
itemMapper.delete(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
.eq(InboundFbaAppointmentItem::getAppointmentId, appointment.getId()));
private AppointmentPlanningPolicy.Group group(List<AppointmentSourceService.Source> rows) {
try { return AppointmentPlanningPolicy.group(rows.stream().map(s->new AppointmentPlanningPolicy.Cargo(s.sourceKey(),s.customerId(),s.originWarehouseId(),s.destinationKey(),"US","US",s.packages(),null,null,s.canFollowup(),String.join(";",s.blockReasons()))).toList()); }
catch(IllegalArgumentException error) {throw bad(error.getMessage());}
}
private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List<Long> requestedLineIds,
LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt,
String status, String remark, LocalDateTime appointmentEndAt) {
List<InboundFbaAppointmentItem> existingItems = items(existing.getId());
Set<Long> existingLineIds = existingItems.stream()
.map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet());
if (!Objects.equals(existing.getAppointmentAt(), appointmentAt)
|| !Objects.equals(existing.getAppointmentEndAt(), appointmentEndAt)
|| !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt)
|| !Objects.equals(existing.getAppointmentStatus(), status)
|| !Objects.equals(existing.getAppointmentRemark(), remark)
|| existingLineIds.size() != requestedLineIds.size()
|| !existingLineIds.equals(new HashSet<>(requestedLineIds))) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Appointment number already exists with different appointment data");
}
return response(existing, existingItems);
}
private String commonFbaWarehouse(List<FbaAppointmentLineSnapshot> lines) {
List<String> warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode)
.map(InboundFbaAppointmentService::trimToNull).toList();
if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Selected inbound lines must belong to the same non-empty FBA warehouse");
}
return warehouses.get(0);
}
private InboundFbaAppointment find(String appointmentNo) {
LambdaQueryWrapper<InboundFbaAppointment> wrapper = new LambdaQueryWrapper<InboundFbaAppointment>()
.eq(InboundFbaAppointment::getAppointmentNo, appointmentNo);
return appointmentMapper.selectOne(wrapper);
}
private List<InboundFbaAppointmentItem> items(Long appointmentId) {
return itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>()
.eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId)
.orderByAsc(InboundFbaAppointmentItem::getLineId));
}
private FbaAppointmentResponse response(InboundFbaAppointment appointment,
List<InboundFbaAppointmentItem> items) {
return new FbaAppointmentResponse(appointment.getId(),
appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(),
appointment.getAppointmentStatus(),
appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(),
appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(),
items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(),
item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode(), item.getCustomerId())).toList(),
appointment.getAppointmentEndAt(), appointment.getVersion());
}
private static void validateWindow(LocalDateTime start, LocalDateTime end) {
if (start == null || (end != null && !end.isAfter(start))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "预约结束时间必须晚于开始时间");
private void apply(InboundFbaAppointment appointment,FbaAppointmentDetails details,List<AppointmentSourceService.Source> rows) {
String status=status(details.appointmentStatus());
String timezone=sources.destinationTimezone(appointment.getFbaWarehouseCode());
if(details.plannedAt()==null && details.plannedEndAt()!=null || details.plannedAt()!=null && details.plannedEndAt()!=null && !details.plannedEndAt().isAfter(details.plannedAt())) throw bad("计划结束时间必须晚于开始时间");
if(details.plannedDate()!=null && details.plannedAt()!=null && !details.plannedDate().equals(details.plannedAt().toLocalDate())) throw bad("计划日期与开始时段不一致,请核对");
appointment.setPlannedDate(details.plannedAt()==null?details.plannedDate():details.plannedAt().toLocalDate());
appointment.setPlannedAt(details.plannedAt());appointment.setPlannedEndAt(details.plannedEndAt());appointment.setTimezone(timezone);
appointment.setAppointmentStatus(status);appointment.setAppointmentRemark(clean(details.appointmentRemark()));
if("SUCCESS".equals(status)) {
if(clean(details.confirmationNo())==null || details.appointmentAt()==null) throw bad("登记对方确认时请填写外部约号和确认时间");
var ids=rows.stream().map(AppointmentSourceService.Source::orderId).filter(Objects::nonNull).distinct().toList();
if(ids.size()!=rows.size() || rows.stream().anyMatch(s->s.packages()==null || s.packages()<=0) || queryMapper.countMissingShipmentReferences(ids)>0) throw bad("正式确认前请核实货量并补齐真实FBA货件号或追踪号");
final AppointmentTimePolicy.Window window;
try {window=AppointmentTimePolicy.resolve(details.appointmentAt(),details.appointmentEndAt(),timezone,offset(details.startOffset()),offset(details.endOffset()));}
catch(IllegalArgumentException | DateTimeException error) {throw bad(error.getMessage());}
appointment.setConfirmationNo(details.confirmationNo().trim());appointment.setAppointmentAt(details.appointmentAt());appointment.setAppointmentEndAt(details.appointmentEndAt());
appointment.setAppointmentStartUtc(LocalDateTime.ofInstant(window.start(),ZoneOffset.UTC));appointment.setAppointmentEndUtc(window.end()==null?null:LocalDateTime.ofInstant(window.end(),ZoneOffset.UTC));
} else {
if(clean(details.confirmationNo())!=null || details.appointmentAt()!=null || details.appointmentEndAt()!=null) throw bad("待确认安排请填写计划时间;取得对方确认后再登记外部约号和确认时段");
appointment.setConfirmationNo(null);appointment.setAppointmentAt(null);appointment.setAppointmentEndAt(null);appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null);
}
}
private static String normalizeStatus(String value) {
String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT);
try {
return FbaAppointmentStatus.valueOf(normalized).name();
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED");
private void writeItems(InboundFbaAppointment appointment,List<AppointmentSourceService.Source> rows,List<InboundFbaAppointmentItem> previous) {
var existing=previous.stream().collect(Collectors.toMap(InboundFbaAppointmentItem::getSourceKey,item->item));
for(var source:rows) {
var item=existing.get(source.sourceKey());boolean fresh=item==null;
if(fresh){item=new InboundFbaAppointmentItem();item.setId(IdWorker.getId());item.setAppointmentId(appointment.getId());item.setCreatedAt(now());}
item.setSourceKey(source.sourceKey());item.setSourceVersion(source.sourceVersion());item.setCustomerId(source.customerId());item.setOrderId(source.orderId());item.setInboundCode(source.inboundCode());
item.setFbaWarehouseCode(source.destinationWarehouseCode());item.setPackageCount(source.packages());item.setActive(true);
try {if(fresh)itemMapper.insert(item);else itemMapper.updateById(item);}
catch(DuplicateKeyException duplicate){throw conflict("所选货物刚被其他安排占用,请刷新后核对");}
}
}
private static String requiredTrimmed(String value, String message) {
String normalized = trimToNull(value);
if (normalized == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
return normalized;
private FbaAppointmentResponse command(String operation,String operator,String key,Object request,Supplier<FbaAppointmentResponse> action) {
if(clean(operator)==null || clean(key)==null || key.length()>128) throw bad("缺少操作人或有效请求标识");
final String hash;
try {hash=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest((operation+"\n"+operator+"\n"+json.writeValueAsString(request)).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(Exception error){throw new IllegalStateException("无法记录预约命令",error);}
var existing=commands.find(key);
if(existing!=null)return replay(existing,hash);
try {commands.begin(key,hash,operation,operator,now());}
catch(DuplicateKeyException duplicate){return replay(commands.find(key),hash);}
var response=action.get();
try {if(commands.finish(key,response.appointmentNo(),json.writeValueAsString(response))!=1)throw conflict("预约命令结果未能保存");}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("无法保存预约命令结果",error);}
return response;
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
private FbaAppointmentResponse replay(FbaAppointmentCommandMapper.Command value,String hash) {
if(value==null || !hash.equals(value.requestHash()) || value.responseJson()==null)throw conflict("重复请求内容不一致或结果尚未就绪,请重新核对");
try {return json.readValue(value.responseJson(),FbaAppointmentResponse.class);}
catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("预约命令记录不可读",error);}
}
private InboundFbaAppointment find(String number,boolean lock) {
if(clean(number)==null)throw bad("预约单号不能为空");
var query=new LambdaQueryWrapper<InboundFbaAppointment>().eq(InboundFbaAppointment::getAppointmentNo,number.trim());if(lock)query.last("FOR UPDATE");return appointmentMapper.selectOne(query);
}
private List<InboundFbaAppointmentItem> items(Long id) {return itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().eq(InboundFbaAppointmentItem::getAppointmentId,id).orderByAsc(InboundFbaAppointmentItem::getId));}
private List<FbaAppointmentResponse> responses(List<InboundFbaAppointment> rows) {
if(rows.isEmpty())return List.of();
var grouped=itemMapper.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().in(InboundFbaAppointmentItem::getAppointmentId,rows.stream().map(InboundFbaAppointment::getId).toList()).orderByAsc(InboundFbaAppointmentItem::getId)).stream().collect(Collectors.groupingBy(InboundFbaAppointmentItem::getAppointmentId));
return rows.stream().map(row->response(row,grouped.getOrDefault(row.getId(),List.of()))).toList();
}
private FbaAppointmentResponse response(InboundFbaAppointment row,List<InboundFbaAppointmentItem> items) {
return new FbaAppointmentResponse(row.getId(),row.getAppointmentNo(),row.getConfirmationNo(),row.getPlannedDate(),row.getPlannedAt(),row.getPlannedEndAt(),row.getAppointmentAt(),row.getAppointmentEndAt(),
utc(row.getAppointmentStartUtc()),utc(row.getAppointmentEndUtc()),row.getTimezone(),row.getAppointmentStatus(),row.getAppointmentRemark(),row.getOriginWarehouseId(),row.getFbaWarehouseCode(),row.getSelectedCount(),row.getCreatedBy(),row.getCreatedAt(),row.getUpdatedAt(),
items.stream().map(item->new FbaAppointmentResponse.Item(item.getId(),item.getSourceKey(),item.getSourceVersion(),item.getOrderId(),item.getInboundCode(),item.getFbaWarehouseCode(),item.getCustomerId(),item.getPackageCount())).toList(),row.getVersion());
}
private static String clean(String value){return value==null || value.isBlank()?null:value.trim();}
private static String status(String value){try{return FbaAppointmentStatus.valueOf(value.trim().toUpperCase(Locale.ROOT)).name();}catch(RuntimeException error){throw bad("请选择有效预约状态");}}
private static ZoneOffset offset(String value){return clean(value)==null?null:ZoneOffset.of(value);}
private static Instant utc(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC);}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);}
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);}
private static ResponseStatusException missing(){return new ResponseStatusException(HttpStatus.NOT_FOUND,"预约或所选货物不存在");}
}