暂存提交
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.lastleg.mapper")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueLastlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Last-leg service status / 尾程服务状态",
|
||||
description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg")
|
||||
public class LastlegHealthController {
|
||||
@Operation(
|
||||
summary = "Get service status / 查询服务状态",
|
||||
description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。"
|
||||
)
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Service is available / 服务可用"
|
||||
)
|
||||
@GetMapping("/status")
|
||||
public ApiResponse<Map<String, String>> status() {
|
||||
return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP"));
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.baiyue.lastleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Tail-leg orders")
|
||||
public class LastlegOrderController {
|
||||
@Resource private LoadingCompletedConsumer consumer;
|
||||
@Resource private LastlegOrderService orderService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping("/internal/lastleg/events")
|
||||
@Operation(summary = "Receive a first-leg loading-completed event")
|
||||
public ApiResponse<Integer> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(consumer.consume(event));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||
@Operation(summary = "Page tail-leg order rows")
|
||||
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid LastlegOrderRequests.Query query) {
|
||||
return ApiResponse.success(orderService.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order")
|
||||
public ApiResponse<LastlegOrderDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id) {
|
||||
return ApiResponse.success(orderService.detail(scope(user, customerId), id));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
|
||||
public ResponseEntity<byte[]> export(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid LastlegOrderRequests.Query query) {
|
||||
byte[] body = orderService.exportCsv(scope(user, customerId), query);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
|
||||
.body(body);
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
|
||||
@Operation(summary = "Transfer one order to a destination warehouse")
|
||||
public ApiResponse<Integer> transferOne(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable long id,
|
||||
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
|
||||
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id)));
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/destination")
|
||||
@Operation(summary = "Transfer multiple orders to a destination warehouse")
|
||||
public ApiResponse<Integer> transferBatch(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
|
||||
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Platform queries must specify a customer");
|
||||
}
|
||||
return requestedCustomer;
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.delivery.mapper")
|
||||
public class DeliveryMapperConfiguration {
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.lastleg.delivery.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg")
|
||||
@Tag(name = "Outbound and delivery")
|
||||
public class DeliveryTransferController {
|
||||
@Resource private DeliveryTransferService service;
|
||||
|
||||
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||
@Operation(summary = "Create one pending outbound order and one pending delivery order")
|
||||
public ApiResponse<DeliveryResponses.TransferResult> transfer(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DeliveryRequests.Transfer request) {
|
||||
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders")
|
||||
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@Valid DeliveryRequests.Query query) {
|
||||
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders/{outboundNo}")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
|
||||
}
|
||||
|
||||
@GetMapping("/delivery-orders")
|
||||
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@Valid DeliveryRequests.Query query) {
|
||||
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/delivery-orders/{deliveryNo}")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer.trim();
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class DeliveryRequests {
|
||||
private DeliveryRequests() {}
|
||||
|
||||
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String documentNo, String warehouseId, String status) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String destinationWarehouseCode) {
|
||||
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class DeliveryResponses {
|
||||
private DeliveryResponses() {}
|
||||
|
||||
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
|
||||
String deliveryNo, String deliveryStatus, int palletCount,
|
||||
int packageCount, boolean replayed) {}
|
||||
|
||||
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId,
|
||||
String status, int palletCount, int packageCount, LocalDateTime createdAt) {}
|
||||
|
||||
public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo,
|
||||
String overseasWarehouseId, String status, int palletCount,
|
||||
int packageCount, LocalDateTime createdAt) {}
|
||||
|
||||
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
|
||||
String orderRefs, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount) {}
|
||||
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
|
||||
PageResult<PalletRow> pallets) {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.delivery.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Delivery document linked one-to-one to its outbound document and dispatch. */
|
||||
@Data
|
||||
@TableName("ll_delivery_order")
|
||||
public class DeliveryOrder {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String deliveryNo;
|
||||
private Long outboundOrderId;
|
||||
private String outboundNo;
|
||||
private Long dispatchOrderId;
|
||||
private String dispatchNo;
|
||||
private String overseasWarehouseId;
|
||||
private String status;
|
||||
private Integer packageCount;
|
||||
private Integer palletCount;
|
||||
private String idempotencyKey;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.delivery.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Outbound document created when a dispatch is transferred to delivery. */
|
||||
@Data
|
||||
@TableName("ll_outbound_order")
|
||||
public class OutboundOrder {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String outboundNo;
|
||||
private Long dispatchOrderId;
|
||||
private String dispatchNo;
|
||||
private String overseasWarehouseId;
|
||||
private String status;
|
||||
private Integer packageCount;
|
||||
private Integer palletCount;
|
||||
private String idempotencyKey;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.delivery.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Pallet-level immutable snapshot belonging to one outbound document. */
|
||||
@Data
|
||||
@TableName("ll_outbound_pallet_item")
|
||||
public class OutboundPalletItem {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private Long outboundOrderId;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private Integer packageCount;
|
||||
private String orderRefs;
|
||||
private String inboundCode;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
|
||||
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
|
||||
DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
|
||||
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.delivery.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
|
||||
@Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
|
||||
List<OutboundPalletItem> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package com.baiyue.lastleg.delivery.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class DeliveryTransferService {
|
||||
@Resource private DispatchOrderMapper dispatchOrders;
|
||||
@Resource private DispatchItemMapper dispatchItems;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private OutboundPalletItemMapper outboundPallets;
|
||||
@Resource private DeliveryOrderMapper deliveryOrders;
|
||||
@Resource private InboundPlanPalletAllocationMapper planAllocations;
|
||||
@Resource private InboundPlanLineMapper planLines;
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper histories;
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
|
||||
DeliveryRequests.Transfer request) {
|
||||
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
|
||||
if (found == null) throw notFound("Dispatch order not found");
|
||||
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (replay != null) {
|
||||
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
|
||||
return result(replay, requireDelivery(customerId, replay.getId()), true);
|
||||
}
|
||||
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true);
|
||||
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
|
||||
throw conflict("Dispatch cannot be transferred to delivery in its current status");
|
||||
}
|
||||
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
|
||||
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
|
||||
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
|
||||
throw conflict("All dispatch pallets must be received before transfer to delivery");
|
||||
}
|
||||
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
|
||||
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
|
||||
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND");
|
||||
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
try {
|
||||
outboundOrders.insert(outbound);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// The unique keys are the final concurrent guard. A client retry must observe
|
||||
// the document created by the winning transaction rather than receive a false failure.
|
||||
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
|
||||
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||
}
|
||||
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||
if (concurrent != null) {
|
||||
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||
}
|
||||
throw conflict("Dispatch was concurrently transferred to delivery");
|
||||
}
|
||||
|
||||
for (DispatchItem source : items) {
|
||||
OutboundPalletItem item = new OutboundPalletItem();
|
||||
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo());
|
||||
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
|
||||
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
|
||||
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
|
||||
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
|
||||
outboundPallets.insert(item);
|
||||
}
|
||||
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
|
||||
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY");
|
||||
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
|
||||
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||
return result(outbound, delivery, false);
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String customerId, DeliveryRequests.Query query) {
|
||||
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
||||
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
|
||||
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
|
||||
.orderByDesc(OutboundOrder::getCreatedAt));
|
||||
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
|
||||
}
|
||||
|
||||
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
|
||||
DeliveryRequests.PalletQuery query) {
|
||||
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
|
||||
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
|
||||
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
|
||||
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
|
||||
.orderByAsc(OutboundPalletItem::getId));
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
|
||||
page(page, page.getRecords().stream().map(this::palletRow).toList()));
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo())
|
||||
.eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId())
|
||||
.eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status())
|
||||
.orderByDesc(DeliveryOrder::getCreatedAt));
|
||||
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
|
||||
}
|
||||
|
||||
public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo,
|
||||
DeliveryRequests.PalletQuery query) {
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("Delivery order not found");
|
||||
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
|
||||
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
|
||||
return outboundDetail(customerId, outbound.getOutboundNo(), query);
|
||||
}
|
||||
|
||||
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
|
||||
String remark, LocalDateTime now) {
|
||||
Set<Long> orderIds = new LinkedHashSet<>();
|
||||
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add);
|
||||
List<Long> palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList();
|
||||
if (!palletIds.isEmpty()) {
|
||||
List<InboundPlanPalletAllocation> allocations = planAllocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>().in(InboundPlanPalletAllocation::getPalletId, palletIds));
|
||||
List<Long> lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
if (!lineIds.isEmpty()) {
|
||||
planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId)
|
||||
.filter(Objects::nonNull).forEach(orderIds::add);
|
||||
}
|
||||
}
|
||||
if (orderIds.isEmpty()) return;
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) {
|
||||
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
|
||||
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
|
||||
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
|
||||
tailOrders.updateById(order);
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
|
||||
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
|
||||
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
|
||||
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
|
||||
history.setCreatedAt(now); histories.insert(history);
|
||||
}
|
||||
}
|
||||
|
||||
private DeliveryOrder requireDelivery(String customerId, Long outboundId) {
|
||||
DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId);
|
||||
if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order");
|
||||
return value;
|
||||
}
|
||||
private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) {
|
||||
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
|
||||
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
|
||||
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
|
||||
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.discard;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.discard.mapper")
|
||||
public class DiscardMapperConfiguration {
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.lastleg.discard.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/cargo-discards")
|
||||
public class CargoDiscardController {
|
||||
@Resource private CargoDiscardService service;
|
||||
|
||||
@GetMapping("/inventory-candidates")
|
||||
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String inboundCode) {
|
||||
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{discardNo}")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
|
||||
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{discardNo}/review")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
|
||||
@Valid @RequestBody CargoDiscardRequests.Review request) {
|
||||
requireApprovalPermission(user);
|
||||
return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requested) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requested == null || requested.isBlank() || "0".equals(requested)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||
}
|
||||
return requested.trim();
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private void requireApprovalPermission(AuthenticatedUser user) {
|
||||
if (user == null || user.permissions() == null
|
||||
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
|
||||
}
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.discard.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class CargoDiscardRequests {
|
||||
private CargoDiscardRequests() {}
|
||||
|
||||
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode,
|
||||
@Positive int discardPackages) {}
|
||||
|
||||
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String discardNo, String inboundCode, String overseasWarehouseId,
|
||||
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.discard.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class CargoDiscardResponses {
|
||||
private CargoDiscardResponses() {}
|
||||
|
||||
public record Candidate(Long orderId, String inboundCode, String inboundOrderNo,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
LocalDateTime inboundCreatedAt, long availablePackages,
|
||||
List<CandidateSku> skus) {}
|
||||
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
|
||||
|
||||
public record Summary(Long id, String discardNo, String inboundCodes,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
|
||||
|
||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||
String productName, int availablePackagesSnapshot, int discardPackages) {}
|
||||
|
||||
public record History(String fromStatus, String toStatus, String operatorId,
|
||||
String remark, LocalDateTime createdAt) {}
|
||||
|
||||
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
|
||||
String reviewedBy, String reviewRemark, List<Item> items,
|
||||
List<History> histories) {}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.lastleg.discard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_cargo_discard_application")
|
||||
public class CargoDiscardApplication {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private String discardNo;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String inboundCodes;
|
||||
private Integer totalPackages;
|
||||
private BigDecimal feePerPackage;
|
||||
private BigDecimal totalFee;
|
||||
private String currency;
|
||||
private String status;
|
||||
private String remark;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String createdBy;
|
||||
private String reviewedBy;
|
||||
private String reviewRemark;
|
||||
private LocalDateTime reviewedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.discard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_cargo_discard_carton")
|
||||
public class CargoDiscardCarton {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long applicationId;
|
||||
private Long itemId;
|
||||
private String customerId;
|
||||
private Long cartonId;
|
||||
private String cartonMark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.discard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_cargo_discard_item")
|
||||
public class CargoDiscardItem {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long applicationId;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private Long profileId;
|
||||
private Long skuInventoryId;
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private Integer availablePackagesSnapshot;
|
||||
private Integer discardPackages;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.discard.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_cargo_discard_status_history")
|
||||
public class CargoDiscardStatusHistory {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long applicationId;
|
||||
private String customerId;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String operatorId;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.discard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardApplication> {
|
||||
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1")
|
||||
CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
|
||||
|
||||
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||
|
||||
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
|
||||
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.discard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardCartonMapper extends BaseMapper<CargoDiscardCarton> {
|
||||
@Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id")
|
||||
List<CargoDiscardCarton> listApplication(@Param("customerId") String customerId,
|
||||
@Param("applicationId") Long applicationId);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.discard.mapper;
|
||||
|
||||
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardInventoryMapper {
|
||||
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
|
||||
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
|
||||
"COUNT(c.id) available_packages FROM ll_order o " +
|
||||
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
|
||||
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
|
||||
"LEFT JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
|
||||
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE' " +
|
||||
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
|
||||
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
|
||||
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
|
||||
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)>0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
|
||||
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
|
||||
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("skuCode") String skuCode);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
|
||||
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
|
||||
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("skuCode") String skuCode, @Param("limit") int limit);
|
||||
|
||||
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
|
||||
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE")
|
||||
DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||
"AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
|
||||
DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId,
|
||||
@Param("orderId") Long orderId,
|
||||
@Param("profileId") Long profileId,
|
||||
@Param("skuCode") String skuCode);
|
||||
|
||||
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
|
||||
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
|
||||
@Param("packages") int packages);
|
||||
|
||||
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
|
||||
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.discard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardItemMapper extends BaseMapper<CargoDiscardItem> {
|
||||
@Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id")
|
||||
List<CargoDiscardItem> listApplication(@Param("customerId") String customerId,
|
||||
@Param("applicationId") Long applicationId);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.discard.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardStatusHistoryMapper extends BaseMapper<CargoDiscardStatusHistory> {
|
||||
@Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
|
||||
List<CargoDiscardStatusHistory> listApplication(@Param("customerId") String customerId,
|
||||
@Param("applicationId") Long applicationId);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.discard.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class CargoDiscardCandidateRow {
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String inboundOrderNo;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private LocalDateTime createdAt;
|
||||
private Long profileId;
|
||||
private Long skuInventoryId;
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private Long availablePackages;
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Final inventory mutation for a warehouse-approved cargo discard. */
|
||||
@Service
|
||||
public class CargoDiscardInventoryAdjustmentService {
|
||||
@Resource private CargoDiscardInventoryMapper inventory;
|
||||
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
|
||||
List<CargoDiscardItem> items,
|
||||
Map<Long, List<CargoDiscardCarton>> linksByItem,
|
||||
String operatorId, LocalDateTime occurredAt) {
|
||||
long beforePackages = 0;
|
||||
long discardedPackages = 0;
|
||||
long afterPackages = 0;
|
||||
for (CargoDiscardItem item : items) {
|
||||
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
||||
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
|
||||
long before = value(sku.getPackageCount());
|
||||
int discarded = item.getDiscardPackages();
|
||||
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
|
||||
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
|
||||
}
|
||||
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
|
||||
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
|
||||
for (CargoDiscardCarton link : links) {
|
||||
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
|
||||
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
|
||||
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
|
||||
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
|
||||
}
|
||||
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
|
||||
long expectedAfter = before - discarded;
|
||||
if (persistedAfter == null || persistedAfter != expectedAfter) {
|
||||
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
|
||||
}
|
||||
beforePackages = Math.addExact(beforePackages, before);
|
||||
discardedPackages = Math.addExact(discardedPackages, discarded);
|
||||
afterPackages = Math.addExact(afterPackages, persistedAfter);
|
||||
}
|
||||
if (beforePackages - discardedPackages != afterPackages
|
||||
|| discardedPackages != application.getTotalPackages()) {
|
||||
throw conflict("Cargo discard application package conservation check failed");
|
||||
}
|
||||
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
|
||||
}
|
||||
|
||||
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
|
||||
CargoDiscardItem item, DropshipCarton carton,
|
||||
String operatorId, LocalDateTime occurredAt) {
|
||||
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
|
||||
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
|
||||
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
|
||||
fact.setBusinessOrderNo(application.getDiscardNo());
|
||||
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
|
||||
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
|
||||
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
|
||||
return fact;
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
private static String remarkSuffix(String value) {
|
||||
return value == null || value.isBlank() ? "" : "; " + value.trim();
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
|
||||
public String auditRemark(String reviewRemark) {
|
||||
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
|
||||
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
|
||||
+ ", inventoryAfter=" + afterPackages;
|
||||
}
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CargoDiscardService {
|
||||
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
|
||||
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||
public static final String APPROVED = "APPROVED";
|
||||
public static final String REJECTED = "REJECTED";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
|
||||
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper discardCartons;
|
||||
@Resource private CargoDiscardStatusHistoryMapper histories;
|
||||
@Resource private CargoDiscardInventoryMapper inventory;
|
||||
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||
|
||||
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
|
||||
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
|
||||
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
|
||||
for (CargoDiscardCandidateRow row : rows) {
|
||||
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
|
||||
value.add(row);
|
||||
}
|
||||
return grouped.values().stream().map(CandidateAccumulator::response).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
|
||||
CargoDiscardRequests.Create request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
CargoDiscardApplication replay = applications.findKey(customerId, key);
|
||||
if (replay != null) {
|
||||
if (!requestHash.equals(replay.getRequestHash())) {
|
||||
throw conflict("Idempotency key was reused with different cargo discard data");
|
||||
}
|
||||
return detail(customerId, replay.getDiscardNo());
|
||||
}
|
||||
Set<String> requestedDimensions = new LinkedHashSet<>();
|
||||
Map<Long, LastlegOrder> orderCache = new HashMap<>();
|
||||
String warehouseId = null;
|
||||
String warehouseName = null;
|
||||
int total = 0;
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
String dimension = input.orderId() + ":" + input.skuCode().trim();
|
||||
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
|
||||
LastlegOrder order = requireDropshipOrder(customerId, input.orderId());
|
||||
orderCache.put(order.getId(), order);
|
||||
if (warehouseId == null) {
|
||||
warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse");
|
||||
warehouseName = trim(order.getOverseasWarehouseName());
|
||||
} else if (!warehouseId.equals(order.getOverseasWarehouseId())) {
|
||||
throw conflict("One cargo discard application cannot span operating warehouses");
|
||||
}
|
||||
total = Math.addExact(total, input.discardPackages());
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
|
||||
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
||||
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
|
||||
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
|
||||
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
|
||||
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
|
||||
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
|
||||
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
|
||||
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
|
||||
application.setCreatedAt(now); application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
|
||||
}
|
||||
history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now);
|
||||
return detail(customerId, application.getDiscardNo());
|
||||
}
|
||||
|
||||
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
|
||||
validateQuery(query);
|
||||
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
.eq(CargoDiscardApplication::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
|
||||
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
|
||||
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
|
||||
query.overseasWarehouseId())
|
||||
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
|
||||
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
||||
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
|
||||
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
|
||||
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
||||
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
|
||||
.toList();
|
||||
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
|
||||
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
|
||||
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId,
|
||||
CargoDiscardRequests.Review request) {
|
||||
String decision = request.decision().trim().toUpperCase();
|
||||
String next = switch (decision) {
|
||||
case "APPROVE", APPROVED -> APPROVED;
|
||||
case "REJECT", REJECTED -> REJECTED;
|
||||
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||
};
|
||||
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
if (next.equals(application.getStatus())) return detail(customerId, discardNo);
|
||||
if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed");
|
||||
|
||||
List<CargoDiscardItem> applicationItems = items.listApplication(customerId, application.getId());
|
||||
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
|
||||
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
||||
String historyRemark = request.remark();
|
||||
if (APPROVED.equals(next)) {
|
||||
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
|
||||
customerId, application, applicationItems, linksByItem, operatorId, now());
|
||||
historyRemark = result.auditRemark(request.remark());
|
||||
} else {
|
||||
release(customerId, links);
|
||||
}
|
||||
|
||||
LocalDateTime reviewedAt = now();
|
||||
application.setStatus(next); application.setReviewedBy(operatorId);
|
||||
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
||||
application.setUpdatedAt(reviewedAt); applications.updateById(application);
|
||||
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
|
||||
return detail(customerId, discardNo);
|
||||
}
|
||||
|
||||
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
|
||||
CargoDiscardRequests.Item input, LocalDateTime now) {
|
||||
String skuCode = input.skuCode().trim();
|
||||
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
|
||||
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
|
||||
}
|
||||
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
|
||||
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
|
||||
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
for (Map.Entry<Long, List<DropshipCarton>> entry : byProfile.entrySet()) {
|
||||
DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode);
|
||||
if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo");
|
||||
CargoDiscardItem item = new CargoDiscardItem();
|
||||
item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId);
|
||||
item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey());
|
||||
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
|
||||
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
|
||||
customerId, order.getId(), entry.getKey(), skuCode)));
|
||||
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
|
||||
for (DropshipCarton carton : entry.getValue()) {
|
||||
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
|
||||
throw conflict("Cargo was reserved concurrently; retry the discard request");
|
||||
}
|
||||
CargoDiscardCarton link = new CargoDiscardCarton();
|
||||
link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId());
|
||||
link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark());
|
||||
link.setCreatedAt(now); discardCartons.insert(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void release(String customerId, List<CargoDiscardCarton> links) {
|
||||
for (CargoDiscardCarton link : links) {
|
||||
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
||||
throw conflict("Reserved cargo status changed before rejection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
|
||||
LastlegOrder order = orders.selectById(orderId);
|
||||
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
|
||||
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
|
||||
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getReviewedAt());
|
||||
}
|
||||
|
||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||
String remark, LocalDateTime createdAt) {
|
||||
CargoDiscardStatusHistory value = new CargoDiscardStatusHistory();
|
||||
value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId);
|
||||
value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId);
|
||||
value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value);
|
||||
}
|
||||
|
||||
private void validateQuery(CargoDiscardRequests.Query query) {
|
||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
|
||||
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw badRequest("createdFrom must not be after createdTo");
|
||||
}
|
||||
}
|
||||
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
byte[] json = objectMapper.writeValueAsBytes(value);
|
||||
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("Cannot hash cargo discard request", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime now() { return LocalDateTime.now(); }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
|
||||
private static final class CandidateAccumulator {
|
||||
private final CargoDiscardCandidateRow first;
|
||||
private final Map<String, CandidateSkuAccumulator> skus = new LinkedHashMap<>();
|
||||
private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; }
|
||||
private void add(CargoDiscardCandidateRow row) {
|
||||
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
|
||||
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
|
||||
}
|
||||
private CargoDiscardResponses.Candidate response() {
|
||||
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
|
||||
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
|
||||
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
|
||||
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
|
||||
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
|
||||
}
|
||||
}
|
||||
private static final class CandidateSkuAccumulator {
|
||||
private final String skuCode;
|
||||
private final String productName;
|
||||
private long packages;
|
||||
private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; }
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.dispatch;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.dispatch.mapper")
|
||||
public class DispatchMapperConfiguration {
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
@Tag(name = "Tail-leg vehicle dispatch")
|
||||
public class DispatchController {
|
||||
@Resource private DispatchService service;
|
||||
@Resource private DispatchPdaWorkflowService pdaWorkflow;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
|
||||
public ApiResponse<List<DispatchPalletCandidate>> candidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String orderNo,
|
||||
@RequestParam(required = false) String fbaWarehouseCode,
|
||||
@RequestParam(required = false) String sourceStage) {
|
||||
return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage));
|
||||
}
|
||||
|
||||
@PostMapping("/pre-arrange")
|
||||
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
|
||||
public ApiResponse<DispatchResponse> preArrange(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchRequests.PreArrange request) {
|
||||
String scopedTenant = scope(user, customerId);
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Page dispatch orders")
|
||||
public ApiResponse<PageResult<DispatchResponse>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid DispatchRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@Operation(summary = "Get dispatch details and all pallet destinations")
|
||||
public ApiResponse<DispatchResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo) {
|
||||
return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo));
|
||||
}
|
||||
|
||||
@PutMapping("/{dispatchNo}")
|
||||
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
|
||||
public ApiResponse<DispatchResponse> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchRequests.Update request) {
|
||||
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{dispatchNo}")
|
||||
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
|
||||
public ApiResponse<Void> cancel(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo) {
|
||||
service.cancel(scope(user, customerId), dispatchNo);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/{dispatchNo}/depart")
|
||||
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
|
||||
public ApiResponse<DispatchPdaResponses.Departed> depart(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@PathVariable String dispatchNo,
|
||||
@Valid @RequestBody DispatchPdaRequests.Depart request) {
|
||||
requirePdaOperate(user);
|
||||
String scopedTenant = scope(user, customerId);
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer;
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private void requirePdaOperate(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class DispatchRequests {
|
||||
private DispatchRequests() {
|
||||
}
|
||||
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
|
||||
String vehiclePlate, String status, String fbaAppointmentNo) {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus) {
|
||||
}
|
||||
|
||||
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||
@Size(max = 255) String route, LocalDateTime departAt,
|
||||
@Size(max = 64) String fbaAppointmentNo,
|
||||
@Size(max = 32) String fbaAppointmentStatus,
|
||||
@Size(max = 32) String vehicleAppointmentStatus,
|
||||
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
|
||||
String driverPhone, String carrierName, String orderNos, String route,
|
||||
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
|
||||
String fbaAppointmentNo, String fbaAppointmentStatus,
|
||||
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
|
||||
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
|
||||
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
|
||||
LocalDateTime createdAt,
|
||||
List<Item> items) {
|
||||
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Immutable pallet/cargo snapshot selected into a dispatch order. */
|
||||
@Data
|
||||
@TableName("ll_dispatch_item")
|
||||
public class DispatchItem {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long dispatchOrderId;
|
||||
private String customerId;
|
||||
private Long planId;
|
||||
private Long planPalletId;
|
||||
private Long warehousePalletId;
|
||||
private Long orderId;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String orderNo;
|
||||
private String orderRefs;
|
||||
private String inboundCode;
|
||||
private String palletNo;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String fbaWarehouseCode;
|
||||
private Integer packageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
|
||||
private String sourceStage;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
|
||||
@Data
|
||||
@TableName("ll_dispatch_order")
|
||||
public class DispatchOrder {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String dispatchNo;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String vehiclePlate;
|
||||
private String driverName;
|
||||
private String driverPhone;
|
||||
private String carrierName;
|
||||
private String route;
|
||||
private LocalDateTime departAt;
|
||||
private String fbaAppointmentNo;
|
||||
private String fbaAppointmentStatus;
|
||||
private String vehicleAppointmentStatus;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private String status;
|
||||
private Integer itemCount;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private Integer shippedPackageCount;
|
||||
private String partialCompletionReason;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.dispatch.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Prevents a source pallet from being added to more than one active dispatch order. */
|
||||
@Data
|
||||
@TableName("ll_dispatch_pallet_lock")
|
||||
public class DispatchPalletLock {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String sourcePalletKey;
|
||||
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
|
||||
private String activeKey;
|
||||
private Long planId;
|
||||
private Long planPalletId;
|
||||
private Long warehousePalletId;
|
||||
private Long dispatchOrderId;
|
||||
private String status;
|
||||
private LocalDateTime lockedAt;
|
||||
private LocalDateTime releasedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
@Select("<script>" +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
|
||||
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
||||
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||
"ORDER BY p.plan_id,p.sequence_no" +
|
||||
"</script>")
|
||||
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
|
||||
@Param("orderNo") String orderNo,
|
||||
@Param("fbaWarehouseCode") String fbaWarehouseCode,
|
||||
@Param("sourceStage") String sourceStage);
|
||||
|
||||
@Select("<script>" +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||
"ORDER BY p.id FOR UPDATE" +
|
||||
"</script>")
|
||||
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
|
||||
@Param("planPalletIds") List<Long> planPalletIds);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
|
||||
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
|
||||
@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
|
||||
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
|
||||
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
|
||||
"WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " +
|
||||
"AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL")
|
||||
int bindReceivedPallets(@Param("customerId") String customerId,
|
||||
@Param("dispatchOrderId") Long dispatchOrderId);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
|
||||
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
|
||||
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
|
||||
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||
DispatchPalletLock lockActive(@Param("customerId") String customerId,
|
||||
@Param("sourcePalletKey") String sourcePalletKey);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.dispatch.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record DispatchPalletCandidate(
|
||||
Long planId,
|
||||
Long planPalletId,
|
||||
Long warehousePalletId,
|
||||
Long orderId,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseName,
|
||||
String orderNo,
|
||||
String orderRefs,
|
||||
String inboundCode,
|
||||
String palletNo,
|
||||
String destinationWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
String fbaWarehouseCode,
|
||||
Integer packageCount,
|
||||
BigDecimal volumeCbm,
|
||||
String sourceStage
|
||||
) {
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String orderNo, String orderRefs, String inboundCode, String palletNo,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
|
||||
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
|
||||
}
|
||||
public String sourcePalletKey() {
|
||||
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
|
||||
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.dispatch.pda;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper")
|
||||
public class DispatchPdaMapperConfiguration {}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
|
||||
public class DispatchPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private DispatchPdaWorkflowService service;
|
||||
|
||||
@PostMapping("/push")
|
||||
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.Push request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
|
||||
}
|
||||
@GetMapping
|
||||
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
|
||||
}
|
||||
@PostMapping("/items/{itemId}/pick")
|
||||
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo, @PathVariable long itemId,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.Pick request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
|
||||
}
|
||||
@PostMapping("/complete")
|
||||
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String dispatchNo,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
|
||||
requireOperate(user);
|
||||
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
|
||||
}
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer.trim();
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||
private void requireOperate(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class DispatchPdaRequests {
|
||||
private DispatchPdaRequests() {}
|
||||
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
|
||||
String warehouseId) {
|
||||
public Push(String idempotencyKey, String operatorId, String operatorName) {
|
||||
this(idempotencyKey, operatorId, operatorName, null);
|
||||
}
|
||||
}
|
||||
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
|
||||
@NotBlank String idempotencyKey, String remark, String warehouseId) {
|
||||
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
|
||||
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
|
||||
}
|
||||
}
|
||||
public record Depart(@NotBlank String idempotencyKey, String remark) {}
|
||||
public record CompletePicking(@NotBlank String idempotencyKey,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class DispatchPdaResponses {
|
||||
private DispatchPdaResponses() {}
|
||||
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
|
||||
List<Item> items) {}
|
||||
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
|
||||
long pickedPackages, List<Position> positions) {}
|
||||
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
|
||||
long pickedPackages) {}
|
||||
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
|
||||
boolean palletScrapped, boolean replayed) {}
|
||||
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
|
||||
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
|
||||
}
|
||||
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
|
||||
long pickedPackages, long remainingPackages, boolean partial,
|
||||
boolean replayed) {
|
||||
public PickingCompleted withReplay() {
|
||||
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
|
||||
remainingPackages, partial, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_dispatch_pda_operation")
|
||||
public class DispatchPdaOperation {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long taskId;
|
||||
private String operationType;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String resultJson;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_dispatch_pda_pick")
|
||||
public class DispatchPdaPick {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long taskId;
|
||||
private Long dispatchItemId;
|
||||
private Long inventoryPositionId;
|
||||
private Long packages;
|
||||
private Long productQuantity;
|
||||
private String idempotencyKey;
|
||||
private LocalDateTime pickedAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */
|
||||
@Data
|
||||
@TableName("ll_dispatch_pda_pick_order")
|
||||
public class DispatchPdaPickOrder {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long pickId;
|
||||
private Long receiptId;
|
||||
private Long orderId;
|
||||
private Long packages;
|
||||
private Long productQuantity;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_dispatch_pda_task")
|
||||
public class DispatchPdaTask {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private String customerId;
|
||||
private Long dispatchOrderId;
|
||||
private String taskNo;
|
||||
private String status;
|
||||
private String operatorId;
|
||||
private String operatorName;
|
||||
private String warehouseId;
|
||||
private LocalDateTime pushedAt;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPdaOperationMapper extends BaseMapper<DispatchPdaOperation> {
|
||||
@Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPdaPickMapper extends BaseMapper<DispatchPdaPick> {
|
||||
@Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id")
|
||||
List<DispatchPdaPick> listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId);
|
||||
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}")
|
||||
long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPdaPickOrderMapper extends BaseMapper<DispatchPdaPickOrder> {
|
||||
@Select("SELECT r.* FROM ll_inbound_stock_receipt r " +
|
||||
"WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " +
|
||||
"AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " +
|
||||
"WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id")
|
||||
List<InboundStockReceipt> availableReceipts(@Param("customerId") String customerId,
|
||||
@Param("positionId") Long positionId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " +
|
||||
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
|
||||
long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " +
|
||||
"WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}")
|
||||
long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id")
|
||||
List<DispatchPdaPickOrder> listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchPdaTaskMapper extends BaseMapper<DispatchPdaTask> {
|
||||
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||
DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
|
||||
@Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1")
|
||||
DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DispatchPdaWorkflowService {
|
||||
@Resource private DispatchOrderMapper orders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private DispatchItemMapper items;
|
||||
@Resource private DispatchPdaTaskMapper tasks;
|
||||
@Resource private DispatchPdaPickMapper picks;
|
||||
@Resource private DispatchPdaOperationMapper operations;
|
||||
@Resource private DispatchPalletLockMapper palletLocks;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private DispatchPickOrderAttributionService pickAttribution;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
requirePdaStatus(order);
|
||||
requireVehicle(order);
|
||||
String warehouseId = warehouse(request.warehouseId());
|
||||
requireDispatchWarehouse(order, warehouseId);
|
||||
DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.Task.class);
|
||||
items.bindReceivedPallets(customerId, order.getId());
|
||||
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
|
||||
if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push");
|
||||
if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
|
||||
throw conflict("All pre-arranged pallets must be received before PDA push");
|
||||
}
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (task == null) {
|
||||
task = new DispatchPdaTask();
|
||||
task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId());
|
||||
task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING");
|
||||
task.setWarehouseId(warehouseId);
|
||||
task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName()));
|
||||
task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task);
|
||||
}
|
||||
if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); }
|
||||
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse");
|
||||
DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems);
|
||||
save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public DispatchPdaResponses.Task task(String customerId, String dispatchNo) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId());
|
||||
if (task == null) throw notFound("Dispatch PDA task not found");
|
||||
return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId,
|
||||
String operatorId, DispatchPdaRequests.Pick request) {
|
||||
DispatchOrder order = requireOrder(customerId, dispatchNo);
|
||||
requirePdaStatus(order);
|
||||
requireVehicle(order);
|
||||
String warehouseId = warehouse(request.warehouseId());
|
||||
requireDispatchWarehouse(order, warehouseId);
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
|
||||
throw conflict("Dispatch PDA task is not available for picking");
|
||||
}
|
||||
if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse");
|
||||
DispatchItem item = items.selectById(itemId);
|
||||
if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) {
|
||||
throw notFound("Dispatch pallet item not found");
|
||||
}
|
||||
if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse");
|
||||
DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.Picked.class);
|
||||
InventoryPosition position = positions.selectById(request.positionId());
|
||||
if (position == null || !customerId.equals(position.getCustomerId())
|
||||
|| item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) {
|
||||
throw conflict("Inventory position does not belong to this dispatch pallet");
|
||||
}
|
||||
long picked = picks.totalItem(customerId, task.getId(), itemId);
|
||||
if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages");
|
||||
String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey();
|
||||
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(),
|
||||
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
|
||||
keyBase + ":ALLOCATE", request.remark());
|
||||
inventory.allocate(customerId, operatorId, move);
|
||||
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(),
|
||||
request.packages(), order.getDispatchNo(), order.getDispatchNo(), null,
|
||||
keyBase + ":PICK", request.remark()));
|
||||
DispatchPdaPick pick = new DispatchPdaPick();
|
||||
pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId());
|
||||
pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages());
|
||||
pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey());
|
||||
pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick);
|
||||
pickAttribution.attribute(customerId, pick);
|
||||
long total = picked + request.packages();
|
||||
order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages()));
|
||||
task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now());
|
||||
if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) {
|
||||
task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART");
|
||||
}
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
orders.updateById(order);
|
||||
tasks.updateById(task);
|
||||
DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false);
|
||||
save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo,
|
||||
DispatchPdaRequests.CompletePicking request) {
|
||||
DispatchOrder found = requireOrder(customerId, dispatchNo);
|
||||
DispatchOrder order = orders.lockById(customerId, found.getId());
|
||||
if (order == null) throw notFound("Dispatch order not found");
|
||||
DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay();
|
||||
requireVehicle(order);
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) {
|
||||
throw conflict("Dispatch PDA task is not available for completion");
|
||||
}
|
||||
List<DispatchItem> source = items.findByDispatchOrder(customerId, order.getId());
|
||||
long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
|
||||
.mapToLong(Integer::longValue).sum();
|
||||
long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum();
|
||||
if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count");
|
||||
boolean partial = picked < planned;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task);
|
||||
order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART");
|
||||
order.setPartialCompletionReason(partial ? request.reason().trim() : null);
|
||||
order.setPickedPackageCount((int) picked);
|
||||
order.setUpdatedAt(now); orders.updateById(order);
|
||||
DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(),
|
||||
order.getStatus(), planned, picked, planned - picked, partial, false);
|
||||
save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */
|
||||
@Transactional
|
||||
public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId,
|
||||
DispatchPdaRequests.Depart request) {
|
||||
DispatchOrder found = requireOrder(customerId, dispatchNo);
|
||||
DispatchOrder order = orders.lockById(customerId, found.getId());
|
||||
if (order == null) throw notFound("Dispatch order not found");
|
||||
DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay();
|
||||
requireVehicle(order);
|
||||
OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId());
|
||||
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
|
||||
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
|
||||
}
|
||||
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
|
||||
if (task == null || !"COMPLETED".equals(task.getStatus())
|
||||
|| !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) {
|
||||
throw conflict("Dispatch must complete PDA picking before departure");
|
||||
}
|
||||
|
||||
List<DispatchItem> dispatchItems = items.findByDispatchOrder(customerId, order.getId());
|
||||
boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus());
|
||||
if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) {
|
||||
throw conflict("Every dispatch pallet must be fully picked before departure");
|
||||
}
|
||||
List<DispatchPdaPick> dispatchPicks = picks.listTask(customerId, task.getId());
|
||||
if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship");
|
||||
long shipped = 0;
|
||||
for (DispatchPdaPick pick : dispatchPicks) {
|
||||
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
|
||||
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
|
||||
pick.getInventoryPositionId(), n(pick.getPackages()),
|
||||
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
|
||||
shipped += n(pick.getPackages());
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now);
|
||||
outbound.setStatus("OUTBOUNDED");
|
||||
outbound.setUpdatedAt(now);
|
||||
outboundOrders.updateById(outbound);
|
||||
order.setShippedPackageCount((int) shipped);
|
||||
order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED");
|
||||
order.setUpdatedAt(now);
|
||||
orders.updateById(order);
|
||||
releasePalletLocks(customerId, order.getId(), now);
|
||||
DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(),
|
||||
order.getStatus(), shipped, false);
|
||||
save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List<DispatchItem> source) {
|
||||
List<DispatchPdaResponses.Item> values = new ArrayList<>();
|
||||
for (DispatchItem item : source) {
|
||||
List<InventoryPosition> palletPositions = item.getWarehousePalletId() == null ? List.of()
|
||||
: positions.listPallet(customerId, item.getWarehousePalletId());
|
||||
values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(),
|
||||
item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(),
|
||||
picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p ->
|
||||
new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()),
|
||||
n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList()));
|
||||
}
|
||||
return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values);
|
||||
}
|
||||
|
||||
private boolean allPicked(String customerId, Long taskId, List<DispatchItem> source) {
|
||||
return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount());
|
||||
}
|
||||
private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) {
|
||||
List<DispatchPalletLock> activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<DispatchPalletLock>()
|
||||
.eq(DispatchPalletLock::getCustomerId, customerId)
|
||||
.eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId)
|
||||
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
|
||||
for (DispatchPalletLock lock : activeLocks) {
|
||||
lock.setStatus("RELEASED");
|
||||
lock.setActiveKey(null);
|
||||
lock.setReleasedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
palletLocks.updateById(lock);
|
||||
}
|
||||
}
|
||||
private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; }
|
||||
private void requirePdaStatus(DispatchOrder order) {
|
||||
if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus())
|
||||
|| "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA");
|
||||
}
|
||||
private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); }
|
||||
private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); }
|
||||
private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) {
|
||||
if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) {
|
||||
throw conflict("Selected warehouse does not match dispatch warehouse");
|
||||
}
|
||||
}
|
||||
private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; }
|
||||
private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); }
|
||||
private <T> T read(DispatchPdaOperation op, Class<T> type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } }
|
||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
|
||||
private static long n(Long value) { return value == null ? 0 : value; }
|
||||
private static long n(Integer value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
|
||||
private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DispatchPickOrderAttributionService {
|
||||
@Resource private DispatchPdaPickOrderMapper attributions;
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper history;
|
||||
|
||||
/** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */
|
||||
public List<OrderPackages> attribute(String customerId, DispatchPdaPick pick) {
|
||||
long remainingPackages = value(pick.getPackages());
|
||||
long totalPackages = remainingPackages;
|
||||
long totalQuantity = value(pick.getProductQuantity());
|
||||
long allocatedPackages = 0;
|
||||
long allocatedQuantity = 0;
|
||||
Map<Long, Long> pickedByOrder = new LinkedHashMap<>();
|
||||
for (InboundStockReceipt receipt : attributions.availableReceipts(customerId,
|
||||
pick.getInventoryPositionId())) {
|
||||
if (remainingPackages == 0) break;
|
||||
long receiptPackages = value(receipt.getReceivedPackages())
|
||||
- attributions.attributedPackages(customerId, receipt.getId());
|
||||
if (receiptPackages <= 0) continue;
|
||||
long packages = Math.min(remainingPackages, receiptPackages);
|
||||
long nextPackages = allocatedPackages + packages;
|
||||
long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages);
|
||||
long quantity = nextQuantity - allocatedQuantity;
|
||||
DispatchPdaPickOrder row = new DispatchPdaPickOrder();
|
||||
row.setId(IdWorker.getId());
|
||||
row.setCustomerId(customerId);
|
||||
row.setPickId(pick.getId());
|
||||
row.setReceiptId(receipt.getId());
|
||||
row.setOrderId(receipt.getOrderId());
|
||||
row.setPackages(packages);
|
||||
row.setProductQuantity(quantity);
|
||||
row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt());
|
||||
attributions.insert(row);
|
||||
pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum);
|
||||
remainingPackages -= packages;
|
||||
allocatedPackages = nextPackages;
|
||||
allocatedQuantity = nextQuantity;
|
||||
}
|
||||
if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders");
|
||||
for (Map.Entry<Long, Long> entry : pickedByOrder.entrySet()) {
|
||||
if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) {
|
||||
throw conflict("Picked packages exceed tail-leg order remaining packages");
|
||||
}
|
||||
}
|
||||
return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
|
||||
}
|
||||
|
||||
/** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */
|
||||
public List<OrderPackages> depart(String customerId, String outboundNo, String dispatchNo, List<DispatchPdaPick> picks,
|
||||
LocalDateTime departedAt) {
|
||||
Map<Long, Long> shippedByOrder = new LinkedHashMap<>();
|
||||
for (DispatchPdaPick pick : picks) {
|
||||
for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) {
|
||||
shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<Long, Long> entry : shippedByOrder.entrySet()) {
|
||||
LastlegOrder before = orders.lockById(customerId, entry.getKey());
|
||||
if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) {
|
||||
throw conflict("Tail-leg order picked packages are inconsistent");
|
||||
}
|
||||
long shipped = value(before.getShippedPackageCount()) + entry.getValue();
|
||||
String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED";
|
||||
LastlegOrderStatusHistory row = new LastlegOrderStatusHistory();
|
||||
row.setOrderId(entry.getKey());
|
||||
row.setFromStatus(before.getCurrentStatus());
|
||||
row.setToStatus(to);
|
||||
row.setEventId("DISPATCH_DEPART:" + dispatchNo);
|
||||
row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped "
|
||||
+ shipped + "/" + value(before.getPackageCount()));
|
||||
row.setCreatedAt(departedAt);
|
||||
history.insert(row);
|
||||
}
|
||||
return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList();
|
||||
}
|
||||
|
||||
public record OrderPackages(Long orderId, long packages) {}
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static long value(Integer value) { return value == null ? 0 : value; }
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DispatchService {
|
||||
public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE";
|
||||
public static final String CANCELLED = "CANCELLED";
|
||||
|
||||
@Resource private DispatchOrderMapper orders;
|
||||
@Resource private DispatchItemMapper items;
|
||||
@Resource private DispatchPalletLockMapper locks;
|
||||
@Resource private DispatchCandidateMapper candidates;
|
||||
|
||||
public List<DispatchPalletCandidate> candidates(String customerId, String orderNo,
|
||||
String fbaWarehouseCode, String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
}
|
||||
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
|
||||
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
|
||||
}
|
||||
List<DispatchPalletCandidate> selected = candidates.lockSelected(customerId, palletIds);
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch");
|
||||
}
|
||||
Set<String> warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet());
|
||||
if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse");
|
||||
}
|
||||
for (DispatchPalletCandidate candidate : selected) {
|
||||
boolean planLocked = candidate.planPalletId() != null
|
||||
&& locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null;
|
||||
boolean receivedLocked = candidate.warehousePalletId() != null
|
||||
&& locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null;
|
||||
if (planLocked || receivedLocked) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another active dispatch: " + candidate.palletNo());
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
dispatch.setId(IdWorker.getId());
|
||||
dispatch.setCustomerId(customerId);
|
||||
dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId());
|
||||
dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId());
|
||||
dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
|
||||
dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected));
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
dispatch.setStatus(PENDING_DEPARTURE);
|
||||
dispatch.setItemCount(selected.size());
|
||||
dispatch.setPalletCount(selected.size());
|
||||
dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||
dispatch.setPickedPackageCount(0);
|
||||
dispatch.setShippedPackageCount(0);
|
||||
dispatch.setCreatedBy(trimToNull(operatorId));
|
||||
dispatch.setCreatedAt(now);
|
||||
dispatch.setUpdatedAt(now);
|
||||
orders.insert(dispatch);
|
||||
|
||||
for (DispatchPalletCandidate candidate : selected) {
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setId(IdWorker.getId());
|
||||
lock.setCustomerId(customerId);
|
||||
lock.setSourcePalletKey(candidate.sourcePalletKey());
|
||||
lock.setActiveKey(candidate.sourcePalletKey());
|
||||
lock.setPlanId(candidate.planId());
|
||||
lock.setPlanPalletId(candidate.planPalletId());
|
||||
lock.setWarehousePalletId(candidate.warehousePalletId());
|
||||
lock.setDispatchOrderId(dispatch.getId());
|
||||
lock.setStatus("ACTIVE");
|
||||
lock.setLockedAt(now);
|
||||
lock.setCreatedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
try {
|
||||
locks.insert(lock);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception);
|
||||
}
|
||||
DispatchItem item = new DispatchItem();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setDispatchOrderId(dispatch.getId());
|
||||
item.setCustomerId(customerId);
|
||||
item.setPlanId(candidate.planId());
|
||||
item.setPlanPalletId(candidate.planPalletId());
|
||||
item.setWarehousePalletId(candidate.warehousePalletId());
|
||||
item.setOrderId(candidate.orderId());
|
||||
item.setOverseasWarehouseId(candidate.overseasWarehouseId());
|
||||
item.setOverseasWarehouseName(candidate.overseasWarehouseName());
|
||||
item.setOrderNo(candidate.orderNo());
|
||||
item.setOrderRefs(candidate.orderRefs());
|
||||
item.setInboundCode(candidate.inboundCode());
|
||||
item.setPalletNo(candidate.palletNo());
|
||||
item.setDestinationWarehouseId(candidate.destinationWarehouseId());
|
||||
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
|
||||
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
|
||||
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
|
||||
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
|
||||
item.setSourceStage(candidate.sourceStage());
|
||||
item.setCreatedAt(now);
|
||||
items.insert(item);
|
||||
}
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
|
||||
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
|
||||
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
|
||||
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
|
||||
.like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo,
|
||||
query.fbaAppointmentNo())
|
||||
.orderByDesc(DispatchOrder::getCreatedAt));
|
||||
List<DispatchResponse> records = page.getRecords().stream()
|
||||
.map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList();
|
||||
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
public DispatchResponse detail(String customerId, String dispatchNo) {
|
||||
DispatchOrder dispatch = find(customerId, dispatchNo);
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) {
|
||||
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
// Vehicle plate is intentionally editable through all dispatch states.
|
||||
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
|
||||
dispatch.setDriverName(trimToNull(request.driverName()));
|
||||
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
|
||||
dispatch.setCarrierName(trimToNull(request.carrierName()));
|
||||
dispatch.setRoute(trimToNull(request.route()));
|
||||
dispatch.setDepartAt(request.departAt());
|
||||
dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo()));
|
||||
dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus()));
|
||||
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
|
||||
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
|
||||
if (StringUtils.hasText(request.status())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Dispatch status is PDA-managed and cannot be changed by the edit endpoint");
|
||||
}
|
||||
dispatch.setUpdatedAt(LocalDateTime.now());
|
||||
orders.updateById(dispatch);
|
||||
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void cancel(String customerId, String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId());
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
dispatch.setStatus(CANCELLED);
|
||||
dispatch.setUpdatedAt(now);
|
||||
orders.updateById(dispatch);
|
||||
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
|
||||
.eq(DispatchPalletLock::getCustomerId, customerId)
|
||||
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
|
||||
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
|
||||
for (DispatchPalletLock lock : activeLocks) {
|
||||
lock.setStatus("RELEASED");
|
||||
lock.setActiveKey(null);
|
||||
lock.setReleasedAt(now);
|
||||
lock.setUpdatedAt(now);
|
||||
locks.updateById(lock);
|
||||
}
|
||||
}
|
||||
|
||||
private DispatchOrder find(String customerId, String dispatchNo) {
|
||||
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
|
||||
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
|
||||
return dispatch;
|
||||
}
|
||||
|
||||
private DispatchResponse response(DispatchOrder order, List<DispatchItem> itemList) {
|
||||
String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream())
|
||||
.distinct().collect(Collectors.joining(","));
|
||||
return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(),
|
||||
order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(),
|
||||
order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(),
|
||||
order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(),
|
||||
order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0),
|
||||
Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||
Objects.requireNonNullElse(order.getPickedPackageCount(), 0),
|
||||
Objects.requireNonNullElse(order.getShippedPackageCount(), 0),
|
||||
Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0)
|
||||
- Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(),
|
||||
order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(),
|
||||
item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(),
|
||||
item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(),
|
||||
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
|
||||
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList());
|
||||
}
|
||||
|
||||
private static String route(List<DispatchPalletCandidate> selected) {
|
||||
return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText)
|
||||
.distinct().collect(Collectors.joining(" -> "));
|
||||
}
|
||||
|
||||
private static List<String> splitOrderRefs(String refs, String fallback) {
|
||||
String value = StringUtils.hasText(refs) ? refs : fallback;
|
||||
return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of();
|
||||
}
|
||||
|
||||
private static String defaultStatus(String status) {
|
||||
return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING";
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.dropship;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.dropship.mapper")
|
||||
public class DropshipMapperConfiguration {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dropship.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/v1/lastleg/dropship")
|
||||
public class DropshipOrderController {
|
||||
@Resource private DropshipOrderService service;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping("/receive")
|
||||
public ApiResponse<Integer> receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody DropshipRequests.Receive request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(service.receive(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.dropship.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
public final class DropshipRequests {
|
||||
private DropshipRequests() {}
|
||||
public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId,
|
||||
@NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {}
|
||||
public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {}
|
||||
public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount,
|
||||
@Positive long productQuantity,
|
||||
@NotEmpty List<@NotBlank String> cartonMarks) {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dropship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data @TableName("ll_dropship_carton")
|
||||
public class DropshipCarton {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private Long profileId;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
private String skuCode;
|
||||
private String cartonMark;
|
||||
private Integer cartonSequence;
|
||||
private String status;
|
||||
private String outboundOrderNo;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.dropship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data @TableName("ll_dropship_carton_ledger")
|
||||
public class DropshipCartonLedger {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private Long cartonId;
|
||||
private String cartonMark;
|
||||
private String skuCode;
|
||||
private String operationType;
|
||||
private Long beforePackages;
|
||||
private Long deltaPackages;
|
||||
private Long afterPackages;
|
||||
private String businessOrderNo;
|
||||
private String idempotencyKey;
|
||||
private String remark;
|
||||
private LocalDateTime occurredAt;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dropship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data @TableName("ll_dropship_order_profile")
|
||||
public class DropshipOrderProfile {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
/** Immutable pallet number snapshot supplied by the first-leg handover event. */
|
||||
private String sourcePalletNo;
|
||||
private Long inventoryPositionId;
|
||||
private String status;
|
||||
private String idempotencyKey;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.dropship.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data @TableName("ll_dropship_sku_inventory")
|
||||
public class DropshipSkuInventory {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String customerId;
|
||||
private Long orderId;
|
||||
private Long profileId;
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private Long packageCount;
|
||||
private Long productQuantity;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.dropship.mapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
public interface DropshipCartonLedgerMapper extends BaseMapper<DropshipCartonLedger> {
|
||||
@Select("SELECT * FROM ll_dropship_carton_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DropshipCartonLedger findIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.lastleg.dropship.mapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
|
||||
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
|
||||
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
|
||||
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.dropship.mapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
|
||||
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
|
||||
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.dropship.mapper;
|
||||
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface DropshipSkuInventoryMapper extends BaseMapper<DropshipSkuInventory> {
|
||||
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code")
|
||||
List<DropshipSkuInventory> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.baiyue.lastleg.dropship.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.entity.*;
|
||||
import com.baiyue.lastleg.dropship.mapper.*;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
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.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class DropshipOrderService {
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private DropshipSkuInventoryMapper skuInventory;
|
||||
@Resource private DropshipCartonMapper cartons;
|
||||
@Resource private DropshipCartonLedgerMapper ledger;
|
||||
|
||||
@Transactional
|
||||
public int receive(DropshipRequests.Receive request) {
|
||||
LastlegOrder order = orders.selectById(request.orderId());
|
||||
if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found");
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order");
|
||||
long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
|
||||
.mapToLong(DropshipRequests.SkuPackages::packageCount).sum();
|
||||
long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
|
||||
.mapToLong(DropshipRequests.SkuPackages::productQuantity).sum();
|
||||
if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) {
|
||||
throw conflict("SKU package total does not equal order package count");
|
||||
}
|
||||
if (order.getProductQuantity() != null && order.getProductQuantity().longValue() != totalQuantity) {
|
||||
throw conflict("SKU product quantity total does not equal order product quantity");
|
||||
}
|
||||
List<DropshipOrderProfile> existing = profiles.listOrder(request.customerId(), request.orderId());
|
||||
if (!existing.isEmpty()) {
|
||||
Set<String> existingPallets = existing.stream().map(DropshipOrderProfile::getSourcePalletNo)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
|
||||
return Math.toIntExact(totalPackages);
|
||||
}
|
||||
|
||||
validateMarks(request);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
|
||||
DropshipOrderProfile profile = profile(request, order, inputPallet, now);
|
||||
profiles.insert(profile);
|
||||
int sequence = 0;
|
||||
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
|
||||
DropshipSkuInventory inventory = new DropshipSkuInventory();
|
||||
inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId());
|
||||
inventory.setProfileId(profile.getId()); inventory.setSkuCode(input.skuCode()); inventory.setProductName(input.productName());
|
||||
inventory.setPackageCount(input.packageCount()); inventory.setProductQuantity(input.productQuantity());
|
||||
inventory.setCreatedAt(now); inventory.setUpdatedAt(now);
|
||||
skuInventory.insert(inventory);
|
||||
for (String mark : input.cartonMarks()) {
|
||||
DropshipCarton carton = new DropshipCarton();
|
||||
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
|
||||
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
|
||||
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
|
||||
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||
cartons.insert(carton);
|
||||
ledger.insert(stockInFact(request, order, carton, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.toIntExact(totalPackages);
|
||||
}
|
||||
|
||||
private void validateMarks(DropshipRequests.Receive request) {
|
||||
Set<String> marks = new HashSet<>();
|
||||
Set<String> palletNos = new HashSet<>();
|
||||
for (DropshipRequests.Pallet pallet : request.pallets()) {
|
||||
if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo());
|
||||
Set<String> skus = new HashSet<>();
|
||||
for (DropshipRequests.SkuPackages item : pallet.skuPackages()) {
|
||||
if (item.packageCount() != item.cartonMarks().size()) {
|
||||
throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode());
|
||||
}
|
||||
if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode());
|
||||
for (String mark : item.cartonMarks()) {
|
||||
if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order,
|
||||
DropshipRequests.Pallet input, LocalDateTime now) {
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile();
|
||||
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
|
||||
profile.setSourcePalletNo(input.palletNo().trim());
|
||||
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
|
||||
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
|
||||
profile.setCreatedAt(now); profile.setUpdatedAt(now);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
|
||||
List<InboundStockReceipt> receipts) {
|
||||
List<DropshipOrderProfile> profilesForOrder = profiles.listOrder(customerId, orderId);
|
||||
if (profilesForOrder.isEmpty()) return;
|
||||
if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return;
|
||||
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
|
||||
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
|
||||
}
|
||||
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
|
||||
.filter(receipt -> receipt.getWarehousePalletId() != null)
|
||||
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId));
|
||||
if (receiptsByPallet.size() != profilesForOrder.size()) {
|
||||
throw conflict("PDA pallet count does not match dropship source pallet count");
|
||||
}
|
||||
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
|
||||
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
|
||||
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
|
||||
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (int index = 0; index < orderedProfiles.size(); index++) {
|
||||
DropshipOrderProfile profile = orderedProfiles.get(index);
|
||||
List<InboundStockReceipt> actual = orderedReceipts.get(index);
|
||||
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
|
||||
long expected = profileCartons.size();
|
||||
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
|
||||
if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot");
|
||||
Long positionId = actual.get(0).getInventoryPositionId();
|
||||
if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) {
|
||||
throw conflict("Dropship source pallet maps to multiple inventory positions");
|
||||
}
|
||||
InboundStockReceipt first = actual.get(0);
|
||||
profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo());
|
||||
profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1);
|
||||
profile.setUpdatedAt(now); profiles.updateById(profile);
|
||||
for (DropshipCarton carton : profileCartons) {
|
||||
carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo());
|
||||
carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now);
|
||||
cartons.updateById(carton);
|
||||
DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now);
|
||||
ledger.insert(activation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId,
|
||||
DropshipCarton carton, LocalDateTime now) {
|
||||
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId);
|
||||
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
|
||||
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
|
||||
fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now);
|
||||
return fact;
|
||||
}
|
||||
|
||||
private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order,
|
||||
DropshipCarton carton, LocalDateTime now) {
|
||||
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||
fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId());
|
||||
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
|
||||
fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L);
|
||||
fact.setBusinessOrderNo(order.getOrderNo());
|
||||
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
|
||||
fact.setOccurredAt(now); fact.setCreatedAt(now);
|
||||
return fact;
|
||||
}
|
||||
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LastlegOrderDetailResponse(
|
||||
LastlegOrder order,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<LastlegOrderStatusHistory> statusHistory,
|
||||
List<DropshipPallet> dropshipPallets
|
||||
) {
|
||||
public record DropshipPallet(
|
||||
Long profileId,
|
||||
String sourcePalletNo,
|
||||
Long warehousePalletId,
|
||||
String warehousePalletNo,
|
||||
Long inventoryPositionId,
|
||||
String status,
|
||||
List<DropshipSkuInventory> skuInventory,
|
||||
List<DropshipCarton> cartons
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class LastlegOrderRequests {
|
||||
private LastlegOrderRequests() {
|
||||
}
|
||||
|
||||
public record Query(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String orderNo,
|
||||
String inboundCode,
|
||||
String sourceOrderNo,
|
||||
String containerNo,
|
||||
String status,
|
||||
String transferWarehouseCode,
|
||||
Boolean abnormal,
|
||||
Boolean detained,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo
|
||||
) {
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
|
||||
public record TransferDestination(
|
||||
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
||||
@NotBlank @Size(max = 128) String warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseCode,
|
||||
@NotBlank @Size(max = 255) String warehouseName,
|
||||
@Size(max = 500) String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record SingleTransferDestination(
|
||||
@NotBlank @Size(max = 128) String warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseCode,
|
||||
@NotBlank @Size(max = 255) String warehouseName,
|
||||
@Size(max = 500) String remark
|
||||
) {
|
||||
public TransferDestination withOrderId(long orderId) {
|
||||
return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "Tail-leg order row at inbound-code granularity")
|
||||
public record LastlegOrderSummary(
|
||||
Long id,
|
||||
String orderNo,
|
||||
String orderType,
|
||||
String sourceInboundId,
|
||||
String inboundCode,
|
||||
String sourceOrderNo,
|
||||
String customerName,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseName,
|
||||
String transferWarehouseId,
|
||||
String transferWarehouseCode,
|
||||
String transferWarehouseName,
|
||||
String fbaWarehouseCode,
|
||||
String containerNo,
|
||||
String containerStatus,
|
||||
Boolean abnormal,
|
||||
Boolean detained,
|
||||
Integer palletCount,
|
||||
Integer packageCount,
|
||||
Integer pickedPackageCount,
|
||||
Integer shippedPackageCount,
|
||||
Integer remainingPackageCount,
|
||||
BigDecimal volumeCbm,
|
||||
Integer productQuantity,
|
||||
BigDecimal volumeWeightKg,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
LocalDateTime actualArrivalAt,
|
||||
LocalDateTime estimatedWarehouseAt,
|
||||
String inboundOrderNo,
|
||||
String outboundOrderNo,
|
||||
String deliveryOrderNo,
|
||||
String relabelOrderNo,
|
||||
String relabelStatus,
|
||||
String palletMergeFlag,
|
||||
String currentStatus,
|
||||
String subOrderStatus,
|
||||
String privateRemark,
|
||||
String customerRemark,
|
||||
String warehouseRemark,
|
||||
LocalDateTime createdAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* API request and response models for last-leg delivery.
|
||||
* / 尾程配送 API 请求与响应模型。
|
||||
*/
|
||||
package com.baiyue.lastleg.dto;
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String customerId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; }
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_order")
|
||||
public class LastlegOrder {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String orderNo;
|
||||
private String customerId;
|
||||
private String orderType;
|
||||
private String inboundCode;
|
||||
private String sourceRegion;
|
||||
private String sourceInboundId;
|
||||
private String rootInboundId;
|
||||
private String sourceOrderId;
|
||||
private String sourceOrderNo;
|
||||
private String rootOrderId;
|
||||
private String customerName;
|
||||
private String overseasWarehouseId;
|
||||
private String overseasWarehouseName;
|
||||
private String fbaWarehouseCode;
|
||||
private String containerNo;
|
||||
private String transferWarehouseId;
|
||||
private String transferWarehouseCode;
|
||||
private String transferWarehouseName;
|
||||
private String containerStatus;
|
||||
private Boolean abnormal;
|
||||
private Boolean detained;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private Integer shippedPackageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
private Integer productQuantity;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
private LocalDateTime actualArrivalAt;
|
||||
private LocalDateTime estimatedWarehouseAt;
|
||||
private String inboundOrderNo;
|
||||
private String outboundOrderNo;
|
||||
private String deliveryOrderNo;
|
||||
private String relabelOrderNo;
|
||||
private String relabelStatus;
|
||||
private String palletMergeFlag;
|
||||
private String currentStatus;
|
||||
private String subOrderStatus;
|
||||
private String privateRemark;
|
||||
private String customerRemark;
|
||||
private String warehouseRemark;
|
||||
private String sourceEventId;
|
||||
private String sourceLoadingPlanId;
|
||||
private Long sourceLoadingPlanVersion;
|
||||
private Long sourceHandoverVersion;
|
||||
private String sourcePayloadHash;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal;
|
||||
@Data @TableName("ll_order_detail") public class LastlegOrderDetail { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; }
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.baiyue.lastleg.entity;
|
||||
import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime;
|
||||
@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; }
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Persistence entities mapped to last-leg MySQL tables.
|
||||
* / 映射尾程 MySQL 数据表的持久化实体。
|
||||
*/
|
||||
package com.baiyue.lastleg.entity;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"})
|
||||
public class InboundPlanMapperConfiguration {
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.lastleg.inboundplan.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inbound-plans")
|
||||
@Tag(name = "Tail-leg inbound plans")
|
||||
public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
|
||||
@PostMapping("/convert")
|
||||
@Operation(summary = "Convert the entire container to one inbound plan")
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
|
||||
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo));
|
||||
}
|
||||
|
||||
@PostMapping("/fba-appointments/batch")
|
||||
@Operation(summary = "Create one FBA appointment for selected inbound lines")
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||
String customerId = scope(user, request.customerId());
|
||||
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Platform request must specify target tenant");
|
||||
}
|
||||
return requestedCustomer;
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchFbaAppointmentRequest(
|
||||
String customerId,
|
||||
@NotEmpty @Size(max = 500) List<@NotNull Long> lineIds,
|
||||
@NotBlank @Size(max = 64) String appointmentNo,
|
||||
@NotNull LocalDateTime appointmentAt,
|
||||
@NotBlank @Size(max = 32) String appointmentStatus,
|
||||
@Size(max = 1000) String appointmentRemark
|
||||
) {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ConvertInboundPlanRequest(
|
||||
@NotNull Long orderId,
|
||||
String customerId
|
||||
) {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record FbaAppointmentResponse(
|
||||
Long id,
|
||||
String customerId,
|
||||
String appointmentNo,
|
||||
LocalDateTime appointmentAt,
|
||||
String appointmentStatus,
|
||||
String appointmentRemark,
|
||||
String fbaWarehouseCode,
|
||||
int selectedCount,
|
||||
String createdBy,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
List<Item> items
|
||||
) {
|
||||
public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode,
|
||||
String fbaWarehouseCode) {
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record InboundPlanResponse(
|
||||
Long id,
|
||||
String planNo,
|
||||
String customerId,
|
||||
String containerNo,
|
||||
String status,
|
||||
int lineCount,
|
||||
int palletCount,
|
||||
BigDecimal totalVolumeCbm,
|
||||
List<Line> lines,
|
||||
List<Pallet> pallets,
|
||||
List<Allocation> allocations
|
||||
) {
|
||||
public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) {
|
||||
}
|
||||
|
||||
public record Pallet(Long id, String palletNo, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo,
|
||||
Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId,
|
||||
String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm,
|
||||
int actualPackageCount) {
|
||||
}
|
||||
|
||||
public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm,
|
||||
int plannedPackageCount, int countedPackageCount, int pickedPackageCount,
|
||||
BigDecimal actualVolumeCbm, String status, int version) {
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_fba_appointment")
|
||||
public class InboundFbaAppointment {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String customerId;
|
||||
private String appointmentNo;
|
||||
private LocalDateTime appointmentAt;
|
||||
private String appointmentStatus;
|
||||
private String appointmentRemark;
|
||||
private String fbaWarehouseCode;
|
||||
private Integer selectedCount;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_fba_appointment_item")
|
||||
public class InboundFbaAppointmentItem {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long appointmentId;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String fbaWarehouseCode;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan")
|
||||
public class InboundPlan {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private String planNo;
|
||||
private String customerId;
|
||||
private String containerNo;
|
||||
private String status;
|
||||
private Integer lineCount;
|
||||
private Integer palletCount;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan_line")
|
||||
public class InboundPlanLine {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long planId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private BigDecimal volumeCbm;
|
||||
private Integer requiredPackageCount;
|
||||
private Integer plannedPalletCount;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan_pallet")
|
||||
public class InboundPlanPallet {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long planId;
|
||||
private String palletNo;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private BigDecimal capacityCbm;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer sequenceNo;
|
||||
private Long warehousePalletId;
|
||||
private Long warehouseLocationId;
|
||||
private Long warehouseResourceLockId;
|
||||
private String assignmentStatus;
|
||||
private LocalDateTime lockedAt;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private Integer actualPackageCount;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan_pallet_allocation")
|
||||
public class InboundPlanPalletAllocation {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long palletId;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer plannedPackageCount;
|
||||
private Integer countedPackageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private String status;
|
||||
private Integer version;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentItemMapper extends BaseMapper<InboundFbaAppointmentItem> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentMapper extends BaseMapper<InboundFbaAppointment> {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFbaAppointmentQueryMapper {
|
||||
@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 " +
|
||||
"FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id " +
|
||||
"WHERE p.customer_id=#{customerId} AND l.id IN " +
|
||||
"<foreach collection='lineIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"ORDER BY l.id FOR UPDATE" +
|
||||
"</script>")
|
||||
List<FbaAppointmentLineSnapshot> lockLines(@Param("customerId") String customerId,
|
||||
@Param("lineIds") List<Long> lineIds);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanLineMapper extends BaseMapper<InboundPlanLine> {
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1")
|
||||
InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanOrderMapper {
|
||||
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
|
||||
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
|
||||
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
|
||||
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
|
||||
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
|
||||
"NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," +
|
||||
"COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," +
|
||||
"COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," +
|
||||
"SUM(CASE WHEN d.id IS NOT NULL " +
|
||||
"AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " +
|
||||
"AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " +
|
||||
"<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " +
|
||||
"AS destinationMismatchCount " +
|
||||
"FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " +
|
||||
"WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " +
|
||||
"GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," +
|
||||
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
|
||||
"ORDER BY o.id FOR UPDATE")
|
||||
List<SourceOrderSnapshot> lockContainerOrders(@Param("customerId") String customerId,
|
||||
@Param("containerNo") String containerNo);
|
||||
|
||||
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
|
||||
"pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{orderId}")
|
||||
int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " +
|
||||
"VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})")
|
||||
int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus,
|
||||
@Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanPalletAllocationMapper extends BaseMapper<InboundPlanPalletAllocation> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanPalletMapper extends BaseMapper<InboundPlanPallet> {
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.inboundplan.model;
|
||||
|
||||
public record FbaAppointmentLineSnapshot(
|
||||
Long lineId,
|
||||
Long planId,
|
||||
Long orderId,
|
||||
String inboundCode,
|
||||
String fbaWarehouseCode
|
||||
) {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.lastleg.inboundplan.model;
|
||||
|
||||
public enum FbaAppointmentStatus {
|
||||
PENDING,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
CANCELLED
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user