This commit is contained in:
2026-08-17 09:18:03 +08:00
parent 8b768125c6
commit d6fe79b0f2
128 changed files with 7053 additions and 76 deletions
+47
View File
@@ -0,0 +1,47 @@
# Lastleg Service 模块指引
## 职责与边界
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。
## 快速索引
| 功能 | 入口 |
|---|---|
| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
| 订单查询与更新 | `service/LastlegOrderService.java` |
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 数据库 | `src/main/resources/db/lastleg.sql` |
| 测试 | `src/test/java/com/baiyue/lastleg/` |
## 事件建单规则
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一事件/loading plan 生成一个共享尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条 `ll_order`,并保存对应明细和初始状态历史。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
## 查询与租户约束
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
## 修改与验证
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
- 定向验证:`mvn -pl lastleg -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.lastleg</groupId><artifactId>baiyue-lastleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>lastleg-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId></dependency>
@@ -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 tenantId,
@Valid LastlegOrderRequests.Query query) {
return ApiResponse.success(orderService.page(scope(user, tenantId), 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 tenantId,
@PathVariable long id) {
return ApiResponse.success(orderService.detail(scope(user, tenantId), 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 tenantId,
@Valid LastlegOrderRequests.Query query) {
byte[] body = orderService.exportCsv(scope(user, tenantId), 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 tenantId,
@PathVariable long id,
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), 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 tenantId,
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
return ApiResponse.success(orderService.transferDestination(scope(user, tenantId), request));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform queries must specify a customer tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.dto;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import java.util.List;
public record LastlegOrderDetailResponse(
LastlegOrder order,
List<LastlegOrderDetail> cargo,
List<LastlegOrderStatusHistory> statusHistory
) {
}
@@ -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,47 @@
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,
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 privateRemark,
String customerRemark,
String warehouseRemark,
LocalDateTime createdAt
) {
}
@@ -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 tenantId; 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,61 @@
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 tenantId;
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 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 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,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 {
}
@@ -0,0 +1,51 @@
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.InboundPlanResponse;
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;
@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.tenantId()), 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 tenantId) {
return ApiResponse.success(service.detailByPlanNo(scope(user, tenantId), planNo));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Platform request must specify target tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotNull;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
String tenantId
) {
}
@@ -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 tenantId,
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 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) {
}
}
@@ -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 tenantId;
private String containerNo;
private String status;
private Integer lineCount;
private Integer palletCount;
private BigDecimal totalVolumeCbm;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
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 destinationWarehouseId;
private String destinationWarehouseCode;
private String destinationWarehouseName;
private BigDecimal volumeCbm;
private Integer requiredPackageCount;
private Integer plannedPalletCount;
}
@@ -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;
}
@@ -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;
}
@@ -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> {
}
@@ -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 tenant_id=#{tenantId} AND container_no=#{containerNo} LIMIT 1")
InboundPlan findByContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo);
}
@@ -0,0 +1,49 @@
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 tenant_id=#{tenantId} AND id=#{orderId} LIMIT 1")
String findContainerNo(@Param("tenantId") String tenantId, @Param("orderId") Long orderId);
@Select("SELECT o.id AS orderId,o.tenant_id AS tenantId,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," +
"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.tenant_id=#{tenantId} AND o.container_no=#{containerNo} " +
"GROUP BY o.id,o.tenant_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count," +
"o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " +
"ORDER BY o.id FOR UPDATE")
List<SourceOrderSnapshot> lockContainerOrders(@Param("tenantId") String tenantId,
@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 tenant_id=#{tenantId} AND id=#{orderId}")
int markPlanned(@Param("tenantId") String tenantId, @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);
}
@@ -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> {
}
@@ -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> {
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.inboundplan.model;
import java.math.BigDecimal;
public record SourceOrderSnapshot(
Long orderId,
String tenantId,
String containerNo,
String inboundCode,
String currentStatus,
String inboundOrderNo,
BigDecimal volumeCbm,
int packageCount,
String destinationWarehouseId,
String destinationWarehouseCode,
String destinationWarehouseName,
int destinationCount,
int destinationMismatchCount
) {
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
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.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;
@RestController
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets")
public class InboundPalletAssignmentController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/{planPalletId}/assign")
public ApiResponse<Void> assign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable long planPalletId,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody PdaRequests.Assign request) {
service.assign(scope(user, tenantId), planNo, planPalletId, request);
return ApiResponse.success(null);
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.inboundplan.pda.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService;
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.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/inbound-plans/{planNo}/pda")
public class InboundPdaController {
@Resource private InboundPdaWorkflowService service;
@PostMapping("/push")
public ApiResponse<PdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody PdaRequests.Push request) {
return ApiResponse.success(service.push(scope(user, tenantId), planNo, request));
}
@GetMapping
public ApiResponse<PdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.task(scope(user, tenantId), planNo));
}
@PostMapping("/allocations/{allocationId}/count")
public ApiResponse<PdaResponses.Operation> count(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
return ApiResponse.success(service.count(scope(user, tenantId), planNo, allocationId, request));
}
@PostMapping("/allocations/{allocationId}/pick")
public ApiResponse<PdaResponses.Operation> pick(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody PdaRequests.PackageOperation request) {
return ApiResponse.success(service.pick(scope(user, tenantId), planNo, allocationId, request));
}
@GetMapping("/allocations/{allocationId}/merge-candidates")
public ApiResponse<List<PdaResponses.MergeCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo,
@PathVariable long allocationId, @RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.mergeCandidates(scope(user, tenantId), planNo, allocationId));
}
@PostMapping("/allocations/{allocationId}/merge")
public ApiResponse<PdaResponses.Merge> merge(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@PathVariable long allocationId,
@RequestParam(required = false) String tenantId,
@Valid @RequestBody PdaRequests.ConfirmMerge request) {
return ApiResponse.success(service.merge(scope(user, tenantId), planNo, allocationId, request));
}
@PostMapping("/complete")
public ApiResponse<PdaResponses.Task> complete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo,
@RequestParam(required = false) String tenantId) {
return ApiResponse.success(service.complete(scope(user, tenantId), planNo));
}
private String scope(AuthenticatedUser user, String requestedTenant) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
if (requestedTenant == null || requestedTenant.isBlank() || "0".equals(requestedTenant)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
}
return requestedTenant;
}
return String.valueOf(user.tenantId());
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public final class PdaRequests {
private PdaRequests() {}
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId,
@NotBlank String idempotencyKey) {}
public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.pda.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class PdaResponses {
private PdaResponses() {}
public record Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {}
public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, Integer remainingPackageCount,
Integer requestedPackageCount, Integer mergePackageCount,
List<MergeCandidate> candidates) {}
public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId,
long orderId, String inboundCode, int availablePackageCount,
BigDecimal availableVolumeCbm) {}
public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId,
int movedPackageCount, BigDecimal movedVolumeCbm,
boolean sourcePalletScrapped) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.inboundplan.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.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("ll_inbound_pallet_merge")
public class InboundPalletMerge {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private Long taskId;
private String mergeGroupNo;
private Long sourcePalletId;
private Long targetPalletId;
private Long sourceAllocationId;
private Long targetAllocationId;
private Long sourceLineId;
private Long targetLineId;
private Long sourceOrderId;
private Long targetOrderId;
private Integer movedPackageCount;
private BigDecimal movedVolumeCbm;
private Boolean sourcePalletScrapped;
private String status;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.inboundplan.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_inbound_pda_operation")
public class InboundPdaOperation {
@TableId(type = IdType.INPUT)
private Long id;
private String tenantId;
private Long taskId;
private Long allocationId;
private String action;
private String idempotencyKey;
private String requestHash;
private String requestJson;
private String resultJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.inboundplan.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_inbound_pda_task")
public class InboundPdaTask {
@TableId(type = IdType.INPUT)
private Long id;
private Long planId;
private String tenantId;
private String taskNo;
private String status;
private String operatorId;
private String operatorName;
private LocalDateTime pushedAt;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.util.Map;
@Component
public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Resource private RestClient.Builder restClientBuilder;
@Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}")
private String baseUrl;
@Value("${baiyue.security.internal-service-token:}")
private String internalToken;
private RestClient client;
@PostConstruct
void initializeClient() {
this.client = restClientBuilder.baseUrl(baseUrl).build();
}
@Override
public long reserve(String tenantId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey) {
ReserveEnvelope response = client.post()
.uri("/internal/v1/warehouse-resource-locks")
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId), "warehouseId", warehouseId,
"palletId", palletId, "locationId", locationId, "businessType", businessType,
"businessId", businessId, "reservationKey", reservationKey))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode());
})
.body(ReserveEnvelope.class);
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response");
return response.data().lockId();
}
@Override
public void scrap(String tenantId, long resourceLockId) {
client.post()
.uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId)
.header("X-Internal-Service-Token", internalToken)
.body(Map.of("tenantId", Long.parseLong(tenantId)))
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode());
})
.toBodilessEntity();
}
private record ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {}
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
public interface WarehouseResourceGateway {
long reserve(String tenantId, long warehouseId, long palletId, long locationId,
String businessType, String businessId, String reservationKey);
void scrap(String tenantId, long resourceLockId);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPalletMergeMapper extends BaseMapper<InboundPalletMerge> {
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaOperationMapper extends BaseMapper<InboundPdaOperation> {
@Select("SELECT * FROM ll_inbound_pda_operation WHERE tenant_id=#{tenantId} AND idempotency_key=#{key} LIMIT 1")
InboundPdaOperation findByKey(@Param("tenantId") String tenantId, @Param("key") String key);
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface InboundPdaTaskMapper extends BaseMapper<InboundPdaTask> {
@Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE")
InboundPdaTask lockByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId);
@Select("SELECT * FROM ll_inbound_pda_task WHERE tenant_id=#{tenantId} AND plan_id=#{planId} LIMIT 1")
InboundPdaTask findByPlan(@Param("tenantId") String tenantId, @Param("planId") Long planId);
}
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.inboundplan.pda.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import java.util.List;
@Repository
public class PdaWorkflowRepository {
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@Resource private InboundPlanPalletAllocationMapper allocations;
@Resource private InboundPdaTaskMapper tasks;
@Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges;
public InboundPlan lockPlan(String tenantId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
}
public InboundPdaTask lockTask(String tenantId, Long planId) { return tasks.lockByPlan(tenantId, planId); }
public InboundPdaTask task(String tenantId, Long planId) { return tasks.findByPlan(tenantId, planId); }
public InboundPdaOperation operation(String tenantId, String key) { return operations.findByKey(tenantId, key); }
public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) {
return allocations.selectOne(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId)
.eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE"));
}
public InboundPlanPallet lockPallet(Long planId, Long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE"));
}
public InboundPlanLine line(Long planId, Long lineId) {
return lines.selectOne(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId));
}
public List<InboundPlanLine> lines(Long planId) {
return lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().eq(InboundPlanLine::getPlanId, planId));
}
public List<InboundPlanPallet> pallets(Long planId) {
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
}
public List<InboundPlanPalletAllocation> allocations(Long planId) {
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, planId));
}
public void insert(InboundPdaTask value) { tasks.insert(value); }
public void insert(InboundPdaOperation value) { operations.insert(value); }
public void insert(InboundPalletMerge value) { merges.insert(value); }
public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); }
public void update(InboundPlan value) { plans.updateById(value); }
public void update(InboundPdaTask value) { tasks.updateById(value); }
public void update(InboundPlanPallet value) { pallets.updateById(value); }
public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); }
}
@@ -0,0 +1,527 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class InboundPdaWorkflowService {
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper;
@PostConstruct
void initializeObjectMapper() {
this.objectMapper = sourceObjectMapper.copy()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
}
@Transactional
public PdaResponses.Task push(String tenantId, String planNo, PdaRequests.Push request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "PUSH", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Task.class);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) {
throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location");
}
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
LocalDateTime now = LocalDateTime.now();
if (task == null) {
task = new InboundPdaTask();
task.setId(IdWorker.getId());
task.setPlanId(plan.getId());
task.setTenantId(tenantId);
task.setTaskNo("PDA" + task.getId());
task.setStatus("PDA_PENDING");
task.setOperatorId(request.operatorId());
task.setOperatorName(request.operatorName());
task.setPushedAt(now);
task.setCreatedAt(now);
task.setUpdatedAt(now);
task.setVersion(0);
repository.insert(task);
}
plan.setStatus("PDA_PENDING");
plan.setUpdatedAt(now);
repository.update(plan);
PdaResponses.Task result = task(task, planNo);
saveOperation(tenantId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public void assign(String tenantId, String planNo, long planPalletId, PdaRequests.Assign request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId);
validateDestinationWarehouse(pallet, request.warehouseId());
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), request.palletId())
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
}
String businessId = planNo + ":" + planPalletId;
long lockId = warehouse.reserve(tenantId, request.warehouseId(), request.palletId(), request.locationId(),
"INBOUND_PLAN_PALLET", businessId, request.idempotencyKey());
pallet.setWarehousePalletId(request.palletId());
pallet.setWarehouseLocationId(request.locationId());
pallet.setWarehouseResourceLockId(lockId);
pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now());
repository.update(pallet);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) {
plan.setStatus("ASSIGNED");
plan.setUpdatedAt(LocalDateTime.now());
repository.update(plan);
}
}
@Transactional
public PdaResponses.Task task(String tenantId, String planNo) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaTask task = repository.task(tenantId, plan.getId());
if (task == null) throw notFound("PDA task not found");
return task(task, planNo);
}
@Transactional
public PdaResponses.Operation count(String tenantId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
int picked = value(allocation.getPickedPackageCount());
if (request.packageCount() < picked) {
throw conflict("Counted package count cannot be less than already picked packages");
}
if (request.packageCount() > value(allocation.getPlannedPackageCount())) {
throw conflict("Counted package count cannot exceed this pallet allocation");
}
allocation.setCountedPackageCount(request.packageCount());
allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount())
? "COUNTED" : "COUNTING");
bump(allocation);
repository.update(allocation);
startPicking(task, plan, request.operatorId(), request.operatorName());
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null);
saveOperation(tenantId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Operation pick(String tenantId, String planNo, long allocationId,
PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId());
int picked = value(allocation.getPickedPackageCount());
int countedRemaining = value(allocation.getCountedPackageCount()) - picked;
if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking");
int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked;
if (request.packageCount() > plannedRemaining) {
int mergeCount = request.packageCount() - Math.max(0, plannedRemaining);
List<PdaResponses.MergeCandidate> candidates = candidates(plan, allocation, mergeCount);
PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line,
candidates, plannedRemaining, request.packageCount(), mergeCount);
saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
if (request.packageCount() > countedRemaining) {
throw conflict("Picked package count cannot exceed the counted remaining packages");
}
BigDecimal perPackage = perPackageVolume(line);
int newPicked = picked + request.packageCount();
allocation.setPickedPackageCount(newPicked);
allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP));
allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING");
bump(allocation);
repository.update(allocation);
InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId());
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount());
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(
perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP));
repository.update(pallet);
startPicking(task, plan, request.operatorId(), request.operatorName());
PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null);
saveOperation(tenantId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public List<PdaResponses.MergeCandidate> mergeCandidates(String tenantId, String planNo,
long allocationId) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId);
return candidates(plan, allocation, 1);
}
@Transactional
public PdaResponses.Merge merge(String tenantId, String planNo, long targetAllocationId,
PdaRequests.ConfirmMerge request) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaOperation replay = replay(tenantId, "MERGE", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Merge.class);
InboundPdaTask task = requireActiveTask(tenantId, plan);
if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ");
InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId);
InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId());
InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId());
InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId());
if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ");
if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse");
if (value(source.getPickedPackageCount()) < request.packageCount()) {
throw conflict("Source pallet does not contain enough picked packages");
}
InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId());
InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId());
BigDecimal movedVolume = perPackageVolume(sourceLine)
.multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP);
if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) {
throw conflict("Source pallet actual package count is insufficient");
}
if (targetPallet.getCapacityCbm() != null
&& volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) {
throw conflict("Merged volume exceeds target pallet capacity");
}
moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume);
sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount());
sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume)));
targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount());
targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume));
boolean scrapped = sourcePallet.getActualPackageCount() == 0;
if (scrapped) {
if (sourcePallet.getWarehouseResourceLockId() == null) {
throw conflict("Source pallet has no warehouse resource lock");
}
warehouse.scrap(tenantId, sourcePallet.getWarehouseResourceLockId());
sourcePallet.setAssignmentStatus("SCRAPPED");
sourcePallet.setWarehouseLocationId(null);
sourcePallet.setWarehouseResourceLockId(null);
}
repository.update(sourcePallet);
repository.update(targetPallet);
String groupNo = "PMG" + IdWorker.getId();
InboundPalletMerge merge = new InboundPalletMerge();
merge.setId(IdWorker.getId());
merge.setTenantId(tenantId);
merge.setTaskId(task.getId());
merge.setMergeGroupNo(groupNo);
merge.setSourcePalletId(sourcePallet.getId());
merge.setTargetPalletId(targetPallet.getId());
merge.setSourceAllocationId(source.getId());
merge.setTargetAllocationId(target.getId());
merge.setSourceLineId(sourceLine.getId());
merge.setTargetLineId(targetLine.getId());
merge.setSourceOrderId(sourceLine.getOrderId());
merge.setTargetOrderId(targetLine.getOrderId());
merge.setMovedPackageCount(request.packageCount());
merge.setMovedVolumeCbm(movedVolume);
merge.setSourcePalletScrapped(scrapped);
merge.setStatus("CONFIRMED");
merge.setCreatedAt(LocalDateTime.now());
repository.insert(merge);
PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(),
targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped);
saveOperation(tenantId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result);
return result;
}
@Transactional
public PdaResponses.Task complete(String tenantId, String planNo) {
InboundPlan plan = requirePlan(tenantId, planNo);
InboundPdaTask task = requireActiveTask(tenantId, plan);
Map<Long, Integer> pickedByLine = new HashMap<>();
for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) {
pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum);
}
List<InboundPlanLine> lines = repository.lines(plan.getId());
if (lines.isEmpty()) throw conflict("Inbound plan has no lines");
for (InboundPlanLine line : lines) {
if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) {
throw conflict("All line picked package totals must exactly equal required package totals");
}
}
LocalDateTime now = LocalDateTime.now();
task.setStatus("COMPLETED");
task.setCompletedAt(now);
task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
plan.setStatus("COMPLETED");
plan.setUpdatedAt(now);
repository.update(plan);
return task(task, planNo);
}
private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount);
source.setCountedPackageCount(Math.max(source.getPickedPackageCount(),
value(source.getCountedPackageCount()) - packageCount));
source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume)));
source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED");
bump(source);
repository.update(source);
InboundPlanPalletAllocation moved = repository.allocations(planId).stream()
.filter(value -> Objects.equals(value.getLineId(), source.getLineId()))
.filter(value -> Objects.equals(value.getPalletId(), targetPalletId))
.findFirst().orElse(null);
if (moved == null) {
moved = new InboundPlanPalletAllocation();
moved.setId(IdWorker.getId());
moved.setPlanId(planId);
moved.setLineId(source.getLineId());
moved.setPalletId(targetPalletId);
moved.setAllocatedVolumeCbm(ZERO_VOLUME);
moved.setPlannedPackageCount(0);
moved.setCountedPackageCount(packageCount);
moved.setPickedPackageCount(packageCount);
moved.setActualVolumeCbm(movedVolume);
moved.setStatus("MERGED_IN");
moved.setVersion(0);
repository.insert(moved);
} else {
moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount);
moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount);
moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume));
moved.setStatus("MERGED_IN");
bump(moved);
repository.update(moved);
}
}
private List<PdaResponses.MergeCandidate> candidates(InboundPlan plan,
InboundPlanPalletAllocation target,
int needed) {
Map<Long, InboundPlanPallet> palletById = new HashMap<>();
for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet);
Map<Long, InboundPlanLine> lineById = new HashMap<>();
for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line);
InboundPlanPallet targetPallet = palletById.get(target.getPalletId());
if (targetPallet == null) throw notFound("Target pallet not found");
List<PdaResponses.MergeCandidate> result = new ArrayList<>();
for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) {
if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue;
int available = value(candidate.getPickedPackageCount());
if (available <= 0) continue;
InboundPlanPallet pallet = palletById.get(candidate.getPalletId());
InboundPlanLine line = lineById.get(candidate.getLineId());
if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue;
if (targetPallet.getCapacityCbm() != null) {
BigDecimal remainingVolume = targetPallet.getCapacityCbm()
.subtract(volume(targetPallet.getActualVolumeCbm()));
BigDecimal packageVolume = perPackageVolume(line);
if (packageVolume.signum() > 0) {
int capacityPackages = remainingVolume.signum() <= 0 ? 0
: remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue();
available = Math.min(available, capacityPackages);
if (available <= 0) continue;
}
}
result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(),
line.getId(), line.getOrderId(), line.getInboundCode(), available,
perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP)));
}
result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed());
return result;
}
private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) {
String leftKey = left.getDestinationWarehouseId() != null
? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode();
String rightKey = right.getDestinationWarehouseId() != null
? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode();
return leftKey != null && leftKey.equals(rightKey);
}
private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) {
String destinationId = pallet.getDestinationWarehouseId();
if (destinationId == null || destinationId.isBlank()) return;
try {
if (Long.parseLong(destinationId) != warehouseId) {
throw conflict("Warehouse resource must belong to the planned destination warehouse");
}
} catch (NumberFormatException exception) {
throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID");
}
}
private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) {
LocalDateTime now = LocalDateTime.now();
if (task.getStartedAt() == null) task.setStartedAt(now);
task.setStatus("PICKING");
task.setOperatorId(operatorId);
task.setOperatorName(operatorName);
task.setUpdatedAt(now);
task.setVersion(value(task.getVersion()) + 1);
repository.update(task);
plan.setStatus("PICKING");
plan.setUpdatedAt(now);
repository.update(plan);
}
private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null
|| !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus()));
}
private InboundPlan requirePlan(String tenantId, String planNo) {
InboundPlan plan = repository.lockPlan(tenantId, planNo);
if (plan == null) throw notFound("Inbound plan not found");
return plan;
}
private InboundPdaTask requireActiveTask(String tenantId, InboundPlan plan) {
InboundPdaTask task = repository.lockTask(tenantId, plan.getId());
if (task == null) throw conflict("Push the inbound plan to PDA before operating");
if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed");
return task;
}
private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) {
InboundPlanPalletAllocation value = repository.lockAllocation(planId, id);
if (value == null) throw notFound("Pallet allocation not found");
return value;
}
private InboundPlanPallet requirePallet(Long planId, Long id) {
InboundPlanPallet value = repository.lockPallet(planId, id);
if (value == null) throw notFound("Planned pallet not found");
return value;
}
private InboundPlanLine requireLine(Long planId, Long id) {
InboundPlanLine value = repository.line(planId, id);
if (value == null) throw notFound("Inbound plan line not found");
return value;
}
private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation,
InboundPlanLine line,
List<PdaResponses.MergeCandidate> candidates,
Integer remaining, Integer requested, Integer merge) {
return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()),
value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()),
volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates);
}
private PdaResponses.Task task(InboundPdaTask task, String planNo) {
return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt());
}
private BigDecimal perPackageVolume(InboundPlanLine line) {
int required = value(line.getRequiredPackageCount());
if (required <= 0) throw conflict("Inbound line required package count must be positive");
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
}
private InboundPdaOperation replay(String tenantId, String action, String key, Object request) {
InboundPdaOperation operation = repository.operation(tenantId, key);
if (operation == null) return null;
if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) {
throw conflict("Idempotency key was already used with a different request");
}
return operation;
}
private void saveOperation(String tenantId, Long taskId, Long allocationId, String action,
String key, Object request, Object result) {
InboundPdaOperation operation = new InboundPdaOperation();
operation.setId(IdWorker.getId());
operation.setTenantId(tenantId);
operation.setTaskId(taskId);
operation.setAllocationId(allocationId);
operation.setAction(action);
operation.setIdempotencyKey(key);
operation.setRequestHash(hash(request));
operation.setRequestJson(write(request));
operation.setResultJson(write(result));
operation.setCreatedAt(LocalDateTime.now());
repository.insert(operation);
}
private String hash(Object value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private String write(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot serialize PDA operation", exception);
}
}
private <T> T read(InboundPdaOperation operation, Class<T> type) {
try {
return objectMapper.readValue(operation.getResultJson(), type);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Cannot read persisted PDA operation result", exception);
}
}
private void bump(InboundPlanPalletAllocation allocation) {
allocation.setVersion(value(allocation.getVersion()) + 1);
}
private static int value(Integer value) { return value == null ? 0 : value; }
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
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); }
}
@@ -0,0 +1,238 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
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.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class InboundPlanService {
@Resource private InboundPlanMapper planMapper;
@Resource private InboundPlanLineMapper lineMapper;
@Resource private InboundPlanPalletMapper palletMapper;
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
@Resource private InboundPlanOrderMapper orderMapper;
@Transactional
public InboundPlanResponse convert(String tenantId, Long selectedOrderId) {
String containerNo = trimToNull(orderMapper.findContainerNo(tenantId, selectedOrderId));
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Order does not exist in this tenant or container number is empty");
}
List<SourceOrderSnapshot> orders = orderMapper.lockContainerOrders(tenantId, containerNo);
if (orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found");
}
InboundPlan existing = planMapper.findByContainer(tenantId, containerNo);
if (existing != null) {
return detail(tenantId, existing);
}
validateOrders(orders);
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
plan.setPlanNo("LIP" + plan.getId());
plan.setTenantId(tenantId);
plan.setContainerNo(containerNo);
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(orders.size());
plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
plan.setCreatedAt(now);
plan.setUpdatedAt(now);
Map<Long, SourceOrderSnapshot> orderById = orders.stream()
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders);
PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream()
.map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount()))
.toList());
plan.setPalletCount(allocation.pallets().size());
planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById);
createAllocations(plan, allocation, lineByOrder, persistedPallets);
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
PalletAllocator.AllocationDraft::orderId,
Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size)));
for (SourceOrderSnapshot order : orders) {
int count = palletCounts.getOrDefault(order.orderId(), 0);
InboundPlanLine line = lineByOrder.get(order.orderId());
line.setPlannedPalletCount(count);
lineMapper.updateById(line);
if (orderMapper.markPlanned(tenantId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
throw new IllegalStateException("Failed to update order " + order.orderId());
}
orderMapper.insertHistory(order.orderId(), order.currentStatus(),
"Container converted to inbound plan " + plan.getPlanNo(), now);
}
return detail(tenantId, plan);
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String tenantId, String planNo) {
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getTenantId, tenantId).eq(InboundPlan::getPlanNo, planNo));
if (plan == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
return detail(tenantId, plan);
}
private Map<Long, InboundPlanLine> createLines(InboundPlan plan, List<SourceOrderSnapshot> orders) {
Map<Long, InboundPlanLine> result = new HashMap<>();
for (SourceOrderSnapshot order : orders) {
InboundPlanLine line = new InboundPlanLine();
line.setId(IdWorker.getId());
line.setPlanId(plan.getId());
line.setOrderId(order.orderId());
line.setInboundCode(order.inboundCode());
line.setDestinationWarehouseId(order.destinationWarehouseId());
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
line.setDestinationWarehouseName(order.destinationWarehouseName());
line.setVolumeCbm(volume(order));
line.setRequiredPackageCount(order.packageCount());
line.setPlannedPalletCount(0);
result.put(order.orderId(), line);
}
return result;
}
private Map<PalletAllocator.PalletDraft, InboundPlanPallet> createPallets(
InboundPlan plan, PalletAllocator.Result allocation, Map<Long, SourceOrderSnapshot> orderById) {
Map<String, SourceOrderSnapshot> warehouseSamples = orderById.values().stream()
.collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left));
Map<PalletAllocator.PalletDraft, InboundPlanPallet> result = new HashMap<>();
for (PalletAllocator.PalletDraft draft : allocation.pallets()) {
SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey());
InboundPlanPallet pallet = new InboundPlanPallet();
pallet.setId(IdWorker.getId());
pallet.setPlanId(plan.getId());
pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1));
pallet.setDestinationWarehouseId(sample.destinationWarehouseId());
pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode());
pallet.setDestinationWarehouseName(sample.destinationWarehouseName());
pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM);
pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm());
pallet.setSequenceNo(result.size() + 1);
pallet.setAssignmentStatus("UNASSIGNED");
pallet.setActualVolumeCbm(BigDecimal.ZERO);
pallet.setActualPackageCount(0);
palletMapper.insert(pallet);
result.put(draft, pallet);
}
return result;
}
private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation,
Map<Long, InboundPlanLine> lineByOrder,
Map<PalletAllocator.PalletDraft, InboundPlanPallet> pallets) {
for (PalletAllocator.AllocationDraft draft : allocation.allocations()) {
InboundPlanPalletAllocation item = new InboundPlanPalletAllocation();
item.setId(IdWorker.getId());
item.setPlanId(plan.getId());
item.setLineId(lineByOrder.get(draft.orderId()).getId());
item.setPalletId(pallets.get(draft.pallet()).getId());
item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm());
item.setPlannedPackageCount(draft.plannedPackageCount());
item.setCountedPackageCount(0);
item.setPickedPackageCount(0);
item.setActualVolumeCbm(BigDecimal.ZERO);
item.setStatus("PENDING_COUNT");
item.setVersion(0);
allocationMapper.insert(item);
}
}
private void validateOrders(List<SourceOrderSnapshot> orders) {
for (SourceOrderSnapshot order : orders) {
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode()
+ " must have one destination warehouse consistent with all cargo details");
}
if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Inbound code " + order.inboundCode() + " already belongs to an inbound plan");
}
if (volume(order).signum() < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode() + " has negative volume");
}
if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound code " + order.inboundCode() + " has invalid package count or volume");
}
}
}
private InboundPlanResponse detail(String tenantId, InboundPlan plan) {
if (!tenantId.equals(plan.getTenantId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
}
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId));
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo));
List<InboundPlanPalletAllocation> allocations = allocationMapper.selectList(
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getPlanId, plan.getId())
.orderByAsc(InboundPlanPalletAllocation::getId));
return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getTenantId(), plan.getContainerNo(),
plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(),
lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(),
line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(),
line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(),
pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(),
pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(),
pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(),
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(),
pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(),
pallet.getActualPackageCount())).toList(),
allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(),
item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(),
item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList());
}
private BigDecimal volume(SourceOrderSnapshot order) {
return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO);
}
private String warehouseKey(SourceOrderSnapshot order) {
String id = trimToNull(order.destinationWarehouseId());
String code = trimToNull(order.destinationWarehouseCode());
return id != null ? "ID:" + id : code == null ? null : "CODE:" + code;
}
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,129 @@
package com.baiyue.lastleg.inboundplan.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class PalletAllocator {
public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000");
private PalletAllocator() {
}
public static Result allocate(List<LineInput> lines) {
Map<String, List<LineInput>> groups = new LinkedHashMap<>();
for (LineInput line : lines) {
if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) {
throw new IllegalArgumentException("Volume must be zero or positive");
}
if (line.packageCount() < 0) {
throw new IllegalArgumentException("Package count must be zero or positive");
}
if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) {
throw new IllegalArgumentException("Positive package count requires positive volume");
}
groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line);
}
List<PalletDraft> pallets = new ArrayList<>();
List<AllocationDraft> allocations = new ArrayList<>();
for (List<LineInput> group : groups.values()) {
PalletDraft current = null;
int sequence = 0;
for (LineInput line : group) {
BigDecimal remaining = line.volumeCbm();
while (remaining.signum() > 0) {
if (current == null || current.remainingCapacity().signum() == 0) {
current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO);
pallets.add(current);
}
BigDecimal allocated = remaining.min(current.remainingCapacity());
current.add(allocated);
allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0));
remaining = remaining.subtract(allocated);
}
}
}
return new Result(List.copyOf(pallets), distributePackages(lines, allocations));
}
private static List<AllocationDraft> distributePackages(List<LineInput> lines,
List<AllocationDraft> allocations) {
Map<Long, LineInput> linesByOrder = new LinkedHashMap<>();
for (LineInput line : lines) {
if (linesByOrder.put(line.orderId(), line) != null) {
throw new IllegalArgumentException("Duplicate order id " + line.orderId());
}
}
Map<Long, List<AllocationDraft>> allocationsByOrder = new LinkedHashMap<>();
for (AllocationDraft allocation : allocations) {
allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation);
}
List<AllocationDraft> result = new ArrayList<>(allocations.size());
Map<AllocationDraft, AllocationDraft> replacements = new LinkedHashMap<>();
for (LineInput line : lines) {
List<AllocationDraft> orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of());
int assigned = 0;
for (int index = 0; index < orderAllocations.size(); index++) {
AllocationDraft allocation = orderAllocations.get(index);
int packages = index == orderAllocations.size() - 1
? line.packageCount() - assigned
: allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount()))
.divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact();
assigned += packages;
replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(),
allocation.allocatedVolumeCbm(), packages));
}
}
for (AllocationDraft allocation : allocations) {
result.add(replacements.get(allocation));
}
return List.copyOf(result);
}
public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) {
}
public static final class PalletDraft {
private final String warehouseKey;
private final int sequenceNo;
private BigDecimal allocatedVolumeCbm;
private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) {
this.warehouseKey = warehouseKey;
this.sequenceNo = sequenceNo;
this.allocatedVolumeCbm = allocatedVolumeCbm;
}
private void add(BigDecimal volume) {
allocatedVolumeCbm = allocatedVolumeCbm.add(volume);
}
public String warehouseKey() {
return warehouseKey;
}
public int sequenceNo() {
return sequenceNo;
}
public BigDecimal allocatedVolumeCbm() {
return allocatedVolumeCbm;
}
public BigDecimal remainingCapacity() {
return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm);
}
}
public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm,
int plannedPackageCount) {
}
public record Result(List<PalletDraft> pallets, List<AllocationDraft> allocations) {
}
}
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventInbox; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LastlegEventInboxMapper extends BaseMapper<LastlegEventInbox> {}
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderDetail; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LastlegOrderDetailMapper extends BaseMapper<LastlegOrderDetail> {}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE tenant_id=#{tenantId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
LastlegOrder findSource(@Param("tenantId") String tenantId,
@Param("sourceRegion") String sourceRegion,
@Param("sourceInboundId") String sourceInboundId);
@Select("SELECT COUNT(*) FROM ll_order WHERE tenant_id=#{tenantId} AND container_no=#{containerNo}")
long countContainer(@Param("tenantId") String tenantId, @Param("containerNo") String containerNo);
}
@@ -0,0 +1,3 @@
package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface LastlegOrderStatusHistoryMapper extends BaseMapper<LastlegOrderStatusHistory> {}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class InternalServiceTokenGuard {
private final String expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { this.expectedToken=expectedToken; }
public void verify(String actualToken) {
if(expectedToken==null||expectedToken.isBlank()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"内部事件接收令牌未配置");
byte[] expected=expectedToken.getBytes(StandardCharsets.UTF_8);
byte[] actual=actualToken==null?new byte[0]:actualToken.getBytes(StandardCharsets.UTF_8);
if(!MessageDigest.isEqual(expected,actual)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部服务令牌无效");
}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Component;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@Component
public class LastlegOrderNoGenerator {
public String next() {
return "LMO" + IdWorker.getIdStr();
}
/** The physical container number is the tail-leg business order grouping boundary. */
public String forContainerNo(String containerNo) {
if (containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("containerNo is required");
}
String normalized = containerNo.trim();
if (normalized.matches("[0-9]{1,29}")) {
return "LMO" + normalized;
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
String digits = new BigInteger(1, digest).toString();
return "LMO" + digits.substring(0, Math.min(29, digits.length()));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}
@@ -0,0 +1,171 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
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.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
public class LastlegOrderService {
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED";
public static final String INBOUND_PLANNED = "INBOUND_PLANNED";
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
public PageResult<LastlegOrderSummary> page(String tenantId, LastlegOrderRequests.Query query) {
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(tenantId, query).orderByDesc(LastlegOrder::getCreatedAt));
return new PageResult<>(page.getRecords().stream().map(this::summary).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
public LastlegOrderDetailResponse detail(String tenantId, long orderId) {
LastlegOrder order = findOwned(tenantId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderStatusHistory> history = historyMapper.selectList(
new LambdaQueryWrapper<LastlegOrderStatusHistory>()
.eq(LastlegOrderStatusHistory::getOrderId, orderId)
.orderByAsc(LastlegOrderStatusHistory::getCreatedAt));
return new LastlegOrderDetailResponse(order, cargo, history);
}
@Transactional
public int transferDestination(String tenantId, LastlegOrderRequests.TransferDestination request) {
List<Long> ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required");
}
List<LastlegOrder> orders = orderMapper.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).in(LastlegOrder::getId, ids).last("FOR UPDATE"));
if (orders.size() != ids.size()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found");
}
for (LastlegOrder order : orders) {
ensureTransferable(order);
}
LocalDateTime now = LocalDateTime.now();
for (LastlegOrder order : orders) {
String fromStatus = order.getCurrentStatus();
order.setTransferWarehouseId(request.warehouseId());
order.setTransferWarehouseCode(request.warehouseCode());
order.setTransferWarehouseName(request.warehouseName());
order.setCurrentStatus(DESTINATION_ASSIGNED);
order.setUpdatedAt(now);
orderMapper.updateById(order);
LastlegOrderDetail detailUpdate = new LastlegOrderDetail();
detailUpdate.setDestinationWarehouseId(request.warehouseId());
detailUpdate.setDestinationWarehouseCode(request.warehouseCode());
detailUpdate.setDestinationWarehouseName(request.warehouseName());
detailMapper.update(detailUpdate, new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getOrderId, order.getId()));
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
history.setOrderId(order.getId());
history.setFromStatus(fromStatus);
history.setToStatus(DESTINATION_ASSIGNED);
history.setRemark(StringUtils.hasText(request.remark())
? request.remark() : "Destination warehouse assigned: " + request.warehouseCode());
history.setCreatedAt(now);
historyMapper.insert(history);
}
return orders.size();
}
public byte[] exportCsv(String tenantId, LastlegOrderRequests.Query query) {
List<LastlegOrder> orders = orderMapper.selectList(filter(tenantId, query)
.orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000"));
List<String> lines = new ArrayList<>();
lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,当前状态,备注(仅自己),备注(客户),备注(仓库)");
for (LastlegOrder order : orders) {
lines.add(String.join(",", List.of(
csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()),
csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()),
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()),
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()),
csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()),
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getPrivateRemark()),
csv(order.getCustomerRemark()), csv(order.getWarehouseRemark()))));
}
return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8);
}
private LambdaQueryWrapper<LastlegOrder> filter(String tenantId, LastlegOrderRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
return new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
.eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status())
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
.eq(query.detained() != null, LastlegOrder::getDetained, query.detained())
.ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo());
}
private LastlegOrder findOwned(String tenantId, long orderId) {
LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getTenantId, tenantId).eq(LastlegOrder::getId, orderId));
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
return order;
}
private void ensureTransferable(LastlegOrder order) {
if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Destination warehouse cannot be changed after inbound plan creation: " + order.getId());
}
}
private LastlegOrderSummary summary(LastlegOrder o) {
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(),
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(),
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(),
o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(),
o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt());
}
private static String csv(Object value) {
if (value == null) return "";
String text = String.valueOf(value);
if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text;
return '"' + text.replace("\"", "\"\"") + '"';
}
}
@@ -0,0 +1,114 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*;
import com.baiyue.lastleg.entity.*;
import com.baiyue.lastleg.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
@Service
public class LoadingCompletedConsumer {
@Resource private LastlegEventInboxMapper inboxMapper;
@Resource private LastlegOrderMapper orderMapper;
@Resource private LastlegOrderDetailMapper detailMapper;
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
@Resource private ObjectMapper objectMapper;
@Resource private LastlegOrderNoGenerator orderNoGenerator;
@Transactional
public int consume(EventEnvelope envelope) {
envelope.requireValid();
if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1)
throw new IllegalArgumentException("不支持的尾程交接事件");
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
throw new IllegalArgumentException("尾程交接事件区域无效");
if (!"LOADING_PLAN".equals(envelope.aggregateType()))
throw new IllegalArgumentException("尾程交接事件聚合类型无效");
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
if(payload.containerNo()==null||payload.containerNo().isBlank())
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
if(existing!=null) {
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
return countOrders(envelope,payload.containerNo());
}
LastlegEventInbox inbox=new LastlegEventInbox();
inbox.setEventId(envelope.eventId()); inbox.setTenantId(envelope.tenantId()); inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash());
inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now());
try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) {
return countOrders(envelope,payload.containerNo());
}
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
for(var source:payload.orders()) createOrder(envelope,payload,source,orderNo);
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
return payload.orders().size();
}
private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
try { return objectMapper.treeToValue(envelope.payload(),FirstLegLoadingPlanCompletedV1.class); }
catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); }
}
private int countOrders(EventEnvelope envelope,String containerNo) {
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
}
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source,String orderNo) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(source)));
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
if(existing!=null) {
if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId());
return;
}
LastlegOrder order=new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setTenantId(event.tenantId()); order.setOrderType("FIRST_LEG");
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName());
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(Objects.requireNonNullElse(source.palletCount(),0));
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
try {
orderMapper.insert(order);
} catch (DuplicateKeyException exception) {
LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(),
source.sourceInboundId());
if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return;
throw exception;
}
for(var item:source.items()) {
LastlegOrderDetail detail=new LastlegOrderDetail();
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount());
detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg());
detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm());
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
}
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory();
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
}
}
+15 -1
View File
@@ -1,6 +1,11 @@
server:
port: ${LASTLEG_PORT:9303}
spring:
sql:
init:
mode: always
encoding: UTF-8
schema-locations: classpath:db/lastleg.sql
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
@@ -26,5 +31,14 @@ spring:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${LASTLEG_DB_USERNAME:root}
password: ${LASTLEG_DB_PASSWORD:}
password: ${LASTLEG_DB_PASSWORD:123456}
management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
+505
View File
@@ -0,0 +1,505 @@
CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_loading_event_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要',
payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON',
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
processed_at DATETIME(3) NULL COMMENT '处理完成时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误',
PRIMARY KEY(event_id),
UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id)
) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型',
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID',
source_order_id VARCHAR(128) COMMENT '来源订单 ID',
source_order_no VARCHAR(128) COMMENT '来源订单号',
root_order_id VARCHAR(128) COMMENT '根订单 ID',
customer_name VARCHAR(255) COMMENT '货主名称',
overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID',
overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称',
transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID',
transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码',
transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称',
fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码',
container_no VARCHAR(64) COMMENT '集装箱号',
container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态',
abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)',
product_quantity INT COMMENT '货物数量',
volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)',
estimated_arrival_at DATETIME(3) COMMENT '预计到港时间',
actual_arrival_at DATETIME(3) COMMENT '实际到港时间',
estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间',
inbound_order_no VARCHAR(128) COMMENT '入库单号',
outbound_order_no VARCHAR(128) COMMENT '出库单号',
delivery_order_no VARCHAR(128) COMMENT '派送单号',
relabel_order_no VARCHAR(128) COMMENT '换标单号',
relabel_status VARCHAR(32) COMMENT '换标状态',
pallet_merge_flag VARCHAR(32) COMMENT '合托标记',
current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
private_remark VARCHAR(1000) COMMENT '备注(仅自己)',
customer_remark VARCHAR(1000) COMMENT '备注(客户)',
warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
source_loading_plan_version BIGINT COMMENT '来源装柜计划版本',
source_handover_version BIGINT COMMENT '交接版本',
source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_order_source (tenant_id,source_region,source_inbound_id),
KEY idx_ll_order_group(tenant_id,order_no),
KEY idx_ll_order_list(tenant_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表';
CREATE TABLE IF NOT EXISTS ll_order_detail (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID',
sku_code VARCHAR(128) COMMENT 'SKU 编码',
product_name_zh VARCHAR(255) COMMENT '中文品名',
product_name_en VARCHAR(255) COMMENT '英文品名',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
product_quantity INT COMMENT '数量',
pallet_count INT COMMENT '托数',
gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)',
net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)',
dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)',
volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)',
destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID',
destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码',
destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称',
fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号',
shipment_no VARCHAR(255) COMMENT '货件编号',
shipping_mark VARCHAR(255) COMMENT '唛头',
PRIMARY KEY(id),
UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id),
KEY idx_ll_detail_order(order_id)
) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细';
CREATE TABLE IF NOT EXISTS ll_loading_order_status_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
from_status VARCHAR(32) COMMENT '变更前状态',
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
event_id VARCHAR(128) COMMENT '触发事件 ID',
remark VARCHAR(500) COMMENT '状态变更备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间',
PRIMARY KEY(id),
KEY idx_ll_loading_history_order(order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单状态变更历史';
CREATE TABLE IF NOT EXISTS ll_event_conflict (
id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID',
tenant_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型',
conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,tenant_id)
) ENGINE=InnoDB COMMENT='事件冲突记录';
CREATE TABLE IF NOT EXISTS ll_event_inbox (
id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID',
tenant_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要',
status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(tenant_id,aggregate_id)
) ENGINE=InnoDB COMMENT='通用事件接收幂等箱';
CREATE TABLE IF NOT EXISTS ll_inbound_order (
id BIGINT NOT NULL COMMENT '入仓单主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID',
source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID',
source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要',
status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(tenant_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id)
) ENGINE=InnoDB COMMENT='尾程入仓单快照';
CREATE TABLE IF NOT EXISTS ll_inbound_order_item (
id BIGINT NOT NULL COMMENT '入仓明细主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID',
source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头',
sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(tenant_id,order_id)
) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照';
CREATE TABLE IF NOT EXISTS ll_order_status_history (
id BIGINT NOT NULL COMMENT '订单状态历史主键', tenant_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID',
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id)
) ENGINE=InnoDB COMMENT='尾程订单状态历史';
-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。
ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱';
ALTER TABLE ll_order COMMENT='尾程订单主表';
ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细';
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
ALTER TABLE ll_event_conflict COMMENT='事件冲突记录';
ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱';
ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照';
ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照';
ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史';
ALTER TABLE ll_event_conflict
MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID',
MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型',
MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间';
ALTER TABLE ll_event_inbox
MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID',
MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间';
ALTER TABLE ll_inbound_order
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间';
ALTER TABLE ll_inbound_order_item
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间';
ALTER TABLE ll_order_status_history
MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间';
ALTER TABLE ll_loading_event_inbox
MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识',
MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本',
MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要',
MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON',
MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED',
MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间',
MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误';
ALTER TABLE ll_order
MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID',
MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型',
MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID',
MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID',
MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号',
MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID',
MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码',
MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号',
MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态',
MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是',
MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是',
MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)',
MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量',
MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)',
MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间',
MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间',
MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间',
MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号',
MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号',
MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号',
MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号',
MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态',
MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记',
MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态',
MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)',
MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)',
MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)',
MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID',
MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本',
MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本',
MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间';
-- Existing installations: convert the row identity to an assigned Snowflake ID and
-- make order_no a container/loading-plan grouping number rather than a row-unique key.
SET @ll_add_transfer_warehouse_id = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_order'
AND column_name = 'transfer_warehouse_id'
);
PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id;
EXECUTE ll_add_transfer_warehouse_id_stmt;
DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt;
SET @ll_add_transfer_warehouse_code = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_order'
AND column_name = 'transfer_warehouse_code'
);
PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code;
EXECUTE ll_add_transfer_warehouse_code_stmt;
DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt;
SET @ll_add_transfer_warehouse_name = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'll_order'
AND column_name = 'transfer_warehouse_name'
);
PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name;
EXECUTE ll_add_transfer_warehouse_name_stmt;
DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt;
ALTER TABLE ll_order
MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID',
MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码',
MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称';
SET @ll_drop_order_no_unique = (
SELECT IF(COUNT(*) > 0,
'ALTER TABLE ll_order DROP INDEX uk_ll_order_no',
'SELECT 1')
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'll_order'
AND index_name = 'uk_ll_order_no' AND non_unique = 0
);
PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique;
EXECUTE ll_drop_order_no_unique_stmt;
DEALLOCATE PREPARE ll_drop_order_no_unique_stmt;
SET @ll_add_order_group_index = (
SELECT IF(COUNT(*) = 0,
'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)',
'SELECT 1')
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'll_order'
AND index_name = 'idx_ll_order_group'
);
PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index;
EXECUTE ll_add_order_group_index_stmt;
DEALLOCATE PREPARE ll_add_order_group_index_stmt;
ALTER TABLE ll_order_detail
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键',
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID',
MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码',
MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名',
MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名',
MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
MODIFY COLUMN product_quantity INT NULL COMMENT '数量',
MODIFY COLUMN pallet_count INT NULL COMMENT '托数',
MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)',
MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)',
MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)',
MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID',
MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码',
MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号',
MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号',
MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头';
CREATE TABLE IF NOT EXISTS ll_inbound_plan (
id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_plan_no (plan_no),
UNIQUE KEY uk_ll_inbound_plan_container (tenant_id, container_no)
) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)',
planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id),
KEY idx_ll_inbound_line_plan (plan_id)
) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
KEY idx_ll_inbound_pallet_plan (plan_id)
) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托';
CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation (
id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)',
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
line_id BIGINT NOT NULL COMMENT '入库计划明细ID',
pallet_id BIGINT NOT NULL COMMENT '计划托盘ID',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id),
KEY idx_ll_inbound_allocation_plan (plan_id),
KEY idx_ll_inbound_allocation_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系';
-- 修复既有入库计划表中的英文注释。
ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单';
ALTER TABLE ll_inbound_plan
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)',
MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号',
MODIFY COLUMN tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量',
MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间';
ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录';
ALTER TABLE ll_inbound_plan_line
MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID',
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID',
MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照',
MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)',
MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量';
ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托';
ALTER TABLE ll_inbound_plan_pallet
MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)',
MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID',
MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号',
MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号';
ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系';
ALTER TABLE ll_inbound_plan_pallet_allocation
MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)',
MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID',
MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID',
MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID',
MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)';
-- Additive inbound picking schema. Guarded statements preserve existing rows on restart.
ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0'));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
CREATE TABLE IF NOT EXISTS ll_inbound_pda_task (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, plan_id BIGINT NOT NULL,
task_no VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
operator_id VARCHAR(128) NULL, operator_name VARCHAR(255) NULL,
pushed_at DATETIME(3) NULL, started_at DATETIME(3) NULL, completed_at DATETIME(3) NULL,
version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (tenant_id, plan_id),
UNIQUE KEY uk_ll_inbound_pda_task_no (task_no)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL,
allocation_id BIGINT NULL, action VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL,
request_hash VARCHAR(128) NOT NULL, request_json LONGTEXT NOT NULL, result_json LONGTEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id, idempotency_key),
KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge (
id BIGINT NOT NULL, tenant_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL,
merge_group_no VARCHAR(64) NOT NULL, source_pallet_id BIGINT NOT NULL, target_pallet_id BIGINT NOT NULL,
source_allocation_id BIGINT NOT NULL, target_allocation_id BIGINT NOT NULL,
source_line_id BIGINT NOT NULL, target_line_id BIGINT NOT NULL,
source_order_id BIGINT NOT NULL, target_order_id BIGINT NOT NULL,
moved_package_count INT NOT NULL, moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0,
source_pallet_scrapped TINYINT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id),
KEY idx_ll_inbound_merge_task (tenant_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no)
) ENGINE=InnoDB;
ALTER TABLE ll_loading_order_status_history
MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键',
MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID',
MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态',
MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态',
MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间';
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
@Test
void customerCannotOverrideItsTenantScope() {
controller.list(user(100L, "MERCHANT"), "200", query);
verify(service).page(eq("100"), eq(query));
}
@Test
void platformOperatorMustExplicitlyChooseCustomerTenant() {
assertThrows(ResponseStatusException.class,
() -> controller.list(user(0L, "PLATFORM"), null, query));
}
@Test
void platformOperatorCanQueryExplicitCustomerTenant() {
controller.list(user(0L, "PLATFORM"), "200", query);
verify(service).page(eq("200"), eq(query));
}
private static AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(1L, tenantId, tenantId, 1L, "tester", appCode, "WEB", Set.of());
}
private LastlegOrderController controller() {
LastlegOrderController controller = new LastlegOrderController();
ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class));
ReflectionTestUtils.setField(controller, "orderService", service);
ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class));
return controller;
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.inboundplan;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InboundPlanSchemaContractTest {
@Test
void planIsUniqueByTenantAndActualContainerNumber() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
assertTrue(stream != null, "lastleg schema must be available");
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase();
assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (tenant_id, container_no)"));
assertTrue(schema.contains("default 'pending_assignment'"));
assertTrue(schema.contains("required_package_count"));
assertTrue(schema.contains("planned_package_count"));
assertTrue(schema.contains("warehouse_resource_lock_id"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_task"));
assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation"));
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
assertFalse(schema.contains("cabinet_no"));
}
}
}
@@ -0,0 +1,357 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPdaWorkflowServiceTest {
@Mock PdaWorkflowRepository repository;
@Mock WarehouseResourceGateway warehouse;
private InboundPdaWorkflowService service;
private InboundPlan plan;
private InboundPdaTask task;
private InboundPlanLine targetLine;
private InboundPlanLine sourceLine;
private InboundPlanPallet targetPallet;
private InboundPlanPallet sourcePallet;
private InboundPlanPallet otherWarehousePallet;
private InboundPlanPalletAllocation target;
private InboundPlanPalletAllocation source;
private InboundPlanPalletAllocation otherWarehouse;
@BeforeEach
void setUp() {
service = new InboundPdaWorkflowService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper();
plan = plan();
task = task();
targetLine = line(11L, 101L, "RK-A", "1.0000", 5);
sourceLine = line(12L, 102L, "RK-B", "0.5000", 2);
targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L);
sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L);
otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L);
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2);
when(repository.lockPlan("100", "LIP1")).thenReturn(plan);
}
@Test
void pushRequiresEveryPalletToBeAssignedAndLocked() {
targetPallet.setWarehouseResourceLockId(null);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
assertThrows(ResponseStatusException.class,
() -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker")));
verify(repository, never()).insert(any(InboundPdaTask.class));
}
@Test
void assignmentReservesWarehouseResourcesAndPersistsTheLock() {
targetPallet.setDestinationWarehouseId("88");
targetPallet.setWarehousePalletId(null);
targetPallet.setWarehouseLocationId(null);
targetPallet.setWarehouseResourceLockId(null);
targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1"))
.thenReturn(999L);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "assign-1"));
assertEquals(188L, targetPallet.getWarehousePalletId());
assertEquals(288L, targetPallet.getWarehouseLocationId());
assertEquals(999L, targetPallet.getWarehouseResourceLockId());
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("ASSIGNED", plan.getStatus());
verify(repository).update(targetPallet);
}
@Test
void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() {
targetPallet.setDestinationWarehouseId("88");
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-retry"));
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(),
any(), any(), any());
assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(),
targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse")));
}
@Test
void countMustPrecedePickAndPickedBoxesDriveActualVolume() {
activeTask();
target.setCountedPackageCount(0);
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker")));
service.count("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(3, "count-3", "7", "worker"));
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(2, "pick-2", "7", "worker"));
assertEquals("PICKED", result.result());
assertEquals(2, target.getPickedPackageCount());
assertEquals("0.4000", target.getActualVolumeCbm().toPlainString());
assertEquals(5, targetPallet.getActualPackageCount());
assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString());
}
@Test
void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
candidateQueries();
var result = service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(5, "pick-over", "7", "worker"));
assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result());
assertEquals(3, result.remainingPackageCount());
assertEquals(2, result.mergePackageCount());
assertEquals(1, result.candidates().size());
assertEquals(source.getId(), result.candidates().get(0).allocationId());
assertEquals(0, target.getPickedPackageCount());
verify(repository, never()).update(target);
verify(repository, never()).update(targetPallet);
verify(repository).insert(any(InboundPdaOperation.class));
}
@Test
void mergeCandidatesOnlyIncludeSameDestinationPallets() {
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
candidateQueries();
var result = service.mergeCandidates("100", "LIP1", target.getId());
assertEquals(1, result.size());
assertEquals("RK-B", result.get(0).inboundCode());
assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString());
}
@Test
void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source));
var result = service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker"));
assertEquals("MERGED", result.result());
assertEquals(sourceLine.getOrderId(), result.sourceOrderId());
assertEquals(targetLine.getOrderId(), result.targetOrderId());
assertTrue(result.sourcePalletScrapped());
assertEquals(0, sourcePallet.getActualPackageCount());
assertEquals(5, targetPallet.getActualPackageCount());
verify(warehouse).scrap("100", 902L);
ArgumentCaptor<InboundPalletMerge> merge = ArgumentCaptor.forClass(InboundPalletMerge.class);
verify(repository).insert(merge.capture());
assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId());
assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId());
assertTrue(merge.getValue().getSourcePalletScrapped());
}
@Test
void mergeCannotExceedTargetPalletVolumeCapacity() {
activeTask();
targetPallet.setActualVolumeCbm(new BigDecimal("1.9000"));
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet);
when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(),
new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker")));
verify(warehouse, never()).scrap(any(), anyLong());
verify(repository, never()).insert(any(InboundPalletMerge.class));
}
@Test
void completionRequiresExactPickedTotalForEveryLine() {
activeTask();
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1"));
assertFalse("COMPLETED".equals(task.getStatus()));
verify(repository, never()).update(plan);
}
@Test
void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask();
target.setPickedPackageCount(3);
InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2);
when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
var result = service.complete("100", "LIP1");
assertEquals("COMPLETED", result.status());
assertEquals("COMPLETED", plan.getStatus());
verify(repository).update(task);
verify(repository).update(plan);
}
@Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
InboundPdaOperation existing = new InboundPdaOperation();
existing.setAction("COUNT");
existing.setRequestHash("different");
when(repository.operation("100", "same-key")).thenReturn(existing);
assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(),
new PdaRequests.PackageOperation(1, "same-key", "7", "worker")));
}
@Test
void completedTaskStillReplaysAnAlreadyPersistedPick() {
activeTask();
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker");
var first = service.pick("100", "LIP1", target.getId(), request);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture());
task.setStatus("COMPLETED");
when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue());
var replay = service.pick("100", "LIP1", target.getId(), request);
assertEquals(first, replay);
assertEquals(1, target.getPickedPackageCount());
verify(repository, times(1)).lockAllocation(plan.getId(), target.getId());
}
private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); }
private void candidateQueries() {
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet));
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine));
when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse));
}
private InboundPlan plan() {
InboundPlan value = new InboundPlan();
value.setId(1L);
value.setTenantId("100");
value.setPlanNo("LIP1");
value.setStatus("ASSIGNED");
return value;
}
private InboundPdaTask task() {
InboundPdaTask value = new InboundPdaTask();
value.setId(2L);
value.setPlanId(1L);
value.setTenantId("100");
value.setTaskNo("PDA2");
value.setStatus("PDA_PENDING");
value.setVersion(0);
return value;
}
private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) {
InboundPlanLine value = new InboundPlanLine();
value.setId(id);
value.setPlanId(1L);
value.setOrderId(orderId);
value.setInboundCode(inboundCode);
value.setDestinationWarehouseId("WH-A");
value.setVolumeCbm(new BigDecimal(volume));
value.setRequiredPackageCount(packages);
return value;
}
private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages,
String actualVolume, long lockId) {
InboundPlanPallet value = new InboundPlanPallet();
value.setId(id);
value.setPlanId(1L);
value.setPalletNo("PLT-" + id);
value.setDestinationWarehouseId(warehouseId);
value.setCapacityCbm(new BigDecimal("2.0000"));
value.setWarehousePalletId(id + 1000);
value.setWarehouseLocationId(id + 2000);
value.setWarehouseResourceLockId(lockId);
value.setAssignmentStatus("ASSIGNED");
value.setActualPackageCount(actualPackages);
value.setActualVolumeCbm(new BigDecimal(actualVolume));
return value;
}
private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId,
int planned, int counted, int picked) {
InboundPlanPalletAllocation value = new InboundPlanPalletAllocation();
value.setId(id);
value.setPlanId(1L);
value.setLineId(lineId);
value.setPalletId(palletId);
value.setPlannedPackageCount(planned);
value.setCountedPackageCount(counted);
value.setPickedPackageCount(picked);
value.setActualVolumeCbm(BigDecimal.ZERO);
value.setVersion(0);
return value;
}
}
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InboundPlanServiceTest {
@Mock InboundPlanMapper planMapper;
@Mock InboundPlanLineMapper lineMapper;
@Mock InboundPlanPalletMapper palletMapper;
@Mock InboundPlanPalletAllocationMapper allocationMapper;
@Mock InboundPlanOrderMapper orderMapper;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
}
@Test
void convertsWholeContainerInOneTransaction() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)
.getAnnotation(Transactional.class));
when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(
order(1L, "RK-1", "1.5000", "WH-A"),
order(2L, "RK-2", "1.0000", "WH-A")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convert("100", 2L);
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
verify(planMapper).insert(plan.capture());
assertEquals(2, plan.getValue().getLineCount());
assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus());
assertEquals(2, plan.getValue().getPalletCount());
assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm());
verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class));
ArgumentCaptor<InboundPlanLine> lines = ArgumentCaptor.forClass(InboundPlanLine.class);
verify(lineMapper, times(2)).insert(lines.capture());
assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum());
ArgumentCaptor<InboundPlanPalletAllocation> allocations =
ArgumentCaptor.forClass(InboundPlanPalletAllocation.class);
verify(allocationMapper, times(3)).insert(allocations.capture());
assertEquals(25, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum());
assertEquals(0, allocations.getAllValues().stream()
.mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum());
assertEquals(3, allocations.getAllValues().stream()
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any());
verify(orderMapper).lockContainerOrders("100", "CONT-1");
assertEquals("CONT-1", result.containerNo());
}
@Test
void repeatedConversionReturnsExistingPlanWithoutWriting() {
InboundPlan existing = new InboundPlan();
existing.setId(9L);
existing.setPlanNo("LIP9");
existing.setTenantId("100");
existing.setContainerNo("CONT-1");
existing.setStatus("PLANNED");
existing.setLineCount(1);
existing.setPalletCount(1);
existing.setTotalVolumeCbm(BigDecimal.ONE);
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing);
assertEquals("LIP9", service.convert("100", 1L).planNo());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
}
@Test
void rejectsMissingDestinationWarehouse() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1");
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
"DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1);
when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid));
assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L));
verify(planMapper, never()).insert(any(InboundPlan.class));
}
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
warehouse, warehouse, warehouse, 1, 0);
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.inboundplan.service;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PalletAllocatorTest {
@Test
void roundsEachDestinationGroupUpToTwoCbmPallets() {
assertEquals(2, allocate("4.0000").pallets().size());
assertEquals(3, allocate("4.0100").pallets().size());
assertEquals(1, allocate("0.1000").pallets().size());
assertEquals(0, allocate("0.0000").pallets().size());
}
@Test
void neverMixesDestinationWarehousesAndCanSplitOneLine() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5),
new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1)));
assertEquals(3, result.pallets().size());
assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count());
assertTrue(result.allocations().stream().allMatch(item ->
item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B")));
}
@Test
void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10)));
assertEquals(List.of(4, 4, 2), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
assertEquals(10, result.allocations().stream()
.mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum());
}
@Test
void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() {
PalletAllocator.Result result = PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1)));
assertEquals(List.of(0, 0, 1), result.allocations().stream()
.map(PalletAllocator.AllocationDraft::plannedPackageCount).toList());
}
private PalletAllocator.Result allocate(String volume) {
return PalletAllocator.allocate(List.of(
new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),
new BigDecimal(volume).signum() == 0 ? 0 : 1)));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.security;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
class InternalServiceTokenGuardTest {
@Test void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token");
assertDoesNotThrow(()->guard.verify("secret-token"));
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.concurrent.*;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class LastlegOrderNoGeneratorTest {
@Test void actualContainerNumberIsStableAndShared() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633"));
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
}
@Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception {
LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator();
String first=beforeRestart.next();
LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator();
assertNotEquals(first,afterRestart.next());
ExecutorService pool=Executors.newFixedThreadPool(8);
try {
var tasks=IntStream.range(0,1000).mapToObj(i->(Callable<String>)afterRestart::next).toList();
Set<String> ids=pool.invokeAll(tasks).stream().map(future->{
try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); }
}).collect(java.util.stream.Collectors.toSet());
assertEquals(1000,ids.size());
} finally { pool.shutdownNow(); }
}
}
@@ -0,0 +1,97 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderServiceTest {
@Mock LastlegOrderMapper orders;
@Mock LastlegOrderDetailMapper details;
@Mock LastlegOrderStatusHistoryMapper histories;
LastlegOrderService service;
@BeforeEach
void setUp() {
service = new LastlegOrderService();
ReflectionTestUtils.setField(service, "orderMapper", orders);
ReflectionTestUtils.setField(service, "detailMapper", details);
ReflectionTestUtils.setField(service, "historyMapper", histories);
}
@Test
void transferUpdatesOrderAndEveryCargoSnapshot() {
LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null);
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertEquals(1, service.transferDestination("100", request));
assertEquals("wh-2", order.getTransferWarehouseId());
assertEquals("LAX-02", order.getTransferWarehouseCode());
assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus());
ArgumentCaptor<LastlegOrderDetail> cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details).update(cargo.capture(), any());
assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode());
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
verify(histories).insert(history.capture());
assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus());
assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus());
}
@Test
void transferRejectsAnOrderAlreadyBoundToInboundPlan() {
LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1");
when(orders.selectList(any())).thenReturn(List.of(order));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null);
assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request));
verify(orders, never()).updateById(any(LastlegOrder.class));
verify(details, never()).update(any(), any());
}
@Test
void batchRejectsMissingOrCrossTenantIdsBeforeWriting() {
when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null)));
var request = new LastlegOrderRequests.TransferDestination(
List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.transferDestination("100", request));
assertTrue(error.getStatusCode().is4xxClientError());
verify(orders, never()).updateById(any(LastlegOrder.class));
}
private static LastlegOrder order(long id, String tenantId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
order.setTenantId(tenantId);
order.setCurrentStatus(status);
order.setInboundOrderNo(inboundOrderNo);
return order;
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
@Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
}