提交
This commit is contained in:
@@ -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`。
|
||||
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
|
||||
|
||||
@@ -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; }
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"})
|
||||
public class InboundPlanMapperConfiguration {
|
||||
}
|
||||
+51
@@ -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());
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@TableName("ll_inbound_plan_pallet_allocation")
|
||||
public class InboundPlanPalletAllocation {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long planId;
|
||||
private Long lineId;
|
||||
private Long palletId;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer plannedPackageCount;
|
||||
private Integer countedPackageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private String status;
|
||||
private Integer version;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanPalletAllocationMapper extends BaseMapper<InboundPlanPalletAllocation> {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanPalletMapper extends BaseMapper<InboundPlanPallet> {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
+43
@@ -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());
|
||||
}
|
||||
}
|
||||
+94
@@ -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) {}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+62
@@ -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) {}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+79
@@ -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); }
|
||||
}
|
||||
+527
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+357
@@ -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;
|
||||
}
|
||||
}
|
||||
+143
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user