feat(appointments): unify pre-inbound planning and confirmed FBA bookings
This commit is contained in:
+3
-3
@@ -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, "预约由平台统一管理");
|
||||
}
|
||||
|
||||
+5
-7
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
) { }
|
||||
+11
-4
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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
|
||||
) { }
|
||||
+3
-6
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
+16
-1
@@ -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;
|
||||
|
||||
+8
-2
@@ -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;
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
+20
-7
@@ -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<>#{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<>#{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<>#{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("""
|
||||
|
||||
+3
-3
@@ -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 >=#{q.createdFrom}</if>" +
|
||||
"<if test='q.createdTo != null'> AND p.created_at <=#{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,
|
||||
|
||||
+4
-4
@@ -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>")
|
||||
|
||||
+195
-239
@@ -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,"预约或所选货物不存在");}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user