This commit is contained in:
wujinliang
2026-08-27 21:49:00 +08:00
87 changed files with 3347 additions and 252 deletions
+1
View File
@@ -40,6 +40,7 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 待分配柜级摘要与整柜入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合分页;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等;详见 `lastleg-service/AGENTS.md` |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -7,7 +7,15 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +23,18 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -30,11 +45,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +60,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -0,0 +1,42 @@
openapi: 3.1.0
info:
title: Baiyue Cross-region Event Contracts
version: 1.0.0
paths: {}
components:
schemas:
FirstLegLoadingPlanCompletedV2:
type: object
required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
properties:
loadingPlanId: {type: string}
containerBookingId: {type: string}
containerNo: {type: string}
cabinetNo: {type: string}
warehouseId: {type: string}
warehouseName: {type: string}
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
orders:
type: array
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
FirstLegLoadingPlanCompletedOrderV2:
type: object
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
properties:
sourceInboundId: {type: string}
sourceOrderId: {type: string}
sourceOrderNo: {type: string}
inboundCode: {type: string}
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
packageCount: {type: integer, minimum: 1}
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
items: {type: array, items: {type: object}}
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
pallets: {type: array, items: {type: object}}
destinationType: {type: string, enum: [FBA, OVERSEAS]}
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
new BigDecimal("0.200"), List.of(item));
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
assertThat(restored.actualPortArrivalAt()).isNull();
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
}
}
@@ -4,17 +4,43 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH",
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
}
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
null, null, null, 1, 2, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
@@ -29,13 +55,14 @@ class FirstLegLoadingPlanCompletedV2Test {
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
@@ -44,7 +71,7 @@ class FirstLegLoadingPlanCompletedV2Test {
void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
@@ -58,7 +85,8 @@ class FirstLegLoadingPlanCompletedV2Test {
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
null, null, null, 2, 20, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
@@ -66,4 +94,29 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
}
@Test
void rejectsMissingImmutableCustomerName() {
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
}
@Test
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
String yaml;
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
"/openapi/cross-region-events-v1.yaml")) {
assertThat(input).isNotNull();
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(yaml)
.contains("required: [sourceInboundId, customerId, customerName")
.contains("customerId: {type: string, minLength: 1")
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
.contains("consumers must not query domestic IAM")
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
}
}
+4
View File
@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -96,6 +97,14 @@ public class GlobalExceptionHandler {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(CannotAcquireLockException.class)
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
CannotAcquireLockException exception, HttpServletRequest request) {
log.warn("Database lock acquisition conflict requestId={} traceId={}",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -16,6 +16,7 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.dao.CannotAcquireLockException;
import java.lang.reflect.Method;
@@ -116,6 +117,18 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
}
@Test
void lockWaitTimeoutMapsToCanonicalConflict() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
var response = handler.handleCannotAcquireLock(
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
}
private void paging(long page) {
}
}
@@ -5365,7 +5365,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -5378,7 +5378,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
@@ -5618,7 +5618,6 @@ BEGIN
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
@@ -6473,9 +6472,7 @@ ON DUPLICATE KEY UPDATE
location_type = VALUES(location_type),
max_weight_kg = VALUES(max_weight_kg),
shared_overflow = VALUES(shared_overflow),
status = 'AVAILABLE',
remark = VALUES(remark),
deleted = 0;
remark = VALUES(remark);
INSERT INTO wh_location (
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
@@ -6494,9 +6491,7 @@ ON DUPLICATE KEY UPDATE
location_type = VALUES(location_type),
max_weight_kg = VALUES(max_weight_kg),
shared_overflow = VALUES(shared_overflow),
status = 'AVAILABLE',
remark = VALUES(remark),
deleted = 0;
remark = VALUES(remark);
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
DELIMITER //
@@ -13772,7 +13767,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'loading_pl
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_at', '开始时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by', '开始装柜人');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by_name', '开始装柜人名称');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'version', '乐观锁版本号');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_at', '确认时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_by', '确认操作人ID');
@@ -16510,6 +16505,69 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
USE baiyue_firstleg;
ALTER TABLE fl_loading_pick_task MODIFY COLUMN task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'
COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED';
ALTER TABLE fl_loading_pick_task DROP CHECK chk_loading_pick_task_status;
ALTER TABLE fl_loading_pick_task ADD CONSTRAINT chk_loading_pick_task_status
CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'));
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '关联PDA任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
command_status VARCHAR(16) NOT NULL COMMENT '命令状态',
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
remark VARCHAR(500) NULL COMMENT '装柜完成备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '后台认领次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许处理时间',
lease_owner VARCHAR(128) NULL COMMENT '后台租约持有者',
lease_until DATETIME(3) NULL COMMENT '后台租约截止时间',
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
failure_code VARCHAR(32) NULL COMMENT '业务或下游错误码',
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
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 '命令更新时间',
completed_at DATETIME(3) NULL COMMENT '命令完成时间',
PRIMARY KEY(id),UNIQUE KEY uk_loading_completion_operation(operation_id),
KEY idx_loading_completion_dispatch(command_status,next_retry_at,lease_until),
CONSTRAINT fk_loading_completion_task FOREIGN KEY(pick_task_id) REFERENCES fl_loading_pick_task(id),
CONSTRAINT chk_loading_completion_status CHECK(command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入库单客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序',
checkpoint_stage VARCHAR(24) 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(command_id,tenant_id,inbound_id),KEY idx_loading_completion_owner(command_id,owner_sequence),
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY(command_id) REFERENCES fl_loading_completion_command(id),
CONSTRAINT chk_loading_completion_checkpoint CHECK(checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_task_before_update BEFORE UPDATE ON fl_loading_pick_task FOR EACH ROW
BEGIN
IF OLD.task_status='COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已完成PDA拣货任务不可修改'; END IF;
IF NEW.loading_plan_id<>OLD.loading_plan_id OR NOT(NEW.container_booking_id<=>OLD.container_booking_id)
OR NOT(NEW.cabinet_no<=>OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务稳定归属不可修改';
END IF;
IF NOT ((OLD.task_status='LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
OR (OLD.task_status='COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
OR (OLD.task_status='COMPLETION_FAILED' AND NEW.task_status='COMPLETING')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务状态流转非法';
END IF;
END$$
DELIMITER ;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -498,6 +498,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
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='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
@@ -200,6 +200,7 @@ public final class LoadingListDtos {
public record History(
Long historyId,
Long itemId,
String operationId,
String actionType,
String fromStatus,
String toStatus,
@@ -125,12 +125,18 @@ public final class LoadingPickDtos {
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark,
Integer attemptCount, LocalDateTime nextRetryAt, String failureStage,
String failureCode, String requestId, String traceId,
LocalDateTime completedAt) {
}
public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId,
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
String currentContainerNo, String sourceType, String recordStatus,
Long mergedIntoContainerBookingId, String taskStatus,
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
Review review, CompletionCommand completionCommand, List<PickItem> items) {
}
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** Durable PDA completion command. Cross-service work is never executed in the acceptance transaction. */
@Getter
@Setter
@TableName("fl_loading_completion_command")
public class LoadingCompletionCommandEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long pickTaskId;
private String operationId;
private String requestHash;
private String commandStatus;
private Integer expectedTaskVersion;
private String remark;
private Long operatorId;
private String operatorName;
private Integer attemptCount;
private LocalDateTime nextRetryAt;
private String leaseOwner;
private LocalDateTime leaseUntil;
private String failureStage;
private String failureCode;
private String failureRequestId;
private String failureTraceId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime completedAt;
}
@@ -61,6 +61,17 @@ public class LoadingRepository {
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId,
String evidenceType, long fileId) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, evidenceType)
.eq(LoadingEvidenceEntity::getFileId, fileId)
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.last("LIMIT 1"));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
@@ -34,6 +34,7 @@ import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -74,6 +75,18 @@ public class LoadingDeliveryService {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
}
LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile(
tenantId, inboundId, request.evidenceType(), request.fileId());
if (existingFile != null) {
String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType())
? request.sealNo().trim() : null;
if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) {
throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄");
}
LoadingActionResponse result = new LoadingActionResponse(
requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
}
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
@@ -103,23 +116,33 @@ public class LoadingDeliveryService {
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName,
key, request, false);
}
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
boolean acceptedPdaCompletion) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING,
key, hash, acceptedPdaCompletion);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target);
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName,
key, request, target, acceptedPdaCompletion);
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
@@ -132,12 +155,14 @@ public class LoadingDeliveryService {
* Resolve and lock the canonical completion row after settlement, then retain optimistic locking
* with that current server version instead of reusing the pre-settlement task snapshot.
*/
@Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long operatorId, String operatorName,
String key, String remark) {
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
}
@Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long evidenceInboundId, long operatorId,
String operatorName, String key, String remark) {
@@ -148,7 +173,7 @@ public class LoadingDeliveryService {
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
}
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key,
new CompleteLoadingRequest(current.version(), remark));
new CompleteLoadingRequest(current.version(), remark), true);
}
/**
@@ -172,7 +197,8 @@ public class LoadingDeliveryService {
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
CompletionTarget target) {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
@@ -180,9 +206,9 @@ public class LoadingDeliveryService {
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
}
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId());
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
@@ -295,9 +321,21 @@ public class LoadingDeliveryService {
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private void requireCompletedPicking(long tenantId, long inboundId) {
if (!canonicalPicking(tenantId, inboundId).completed()) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
/** worker 先完成各入库单,之后才完成柜次计划;这里不能循环要求计划已经 COMPLETED。 */
private void requireLoadingPlanGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
if (acceptedPdaCompletion) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return;
}
loadingModelBridge.requireCompleted(tenantId, inboundId);
}
/** completion worker 已持有 durable lease;此时 task 尚为 COMPLETING,不能循环要求最终 COMPLETED。 */
private void requirePickingGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
CanonicalPicking picking = canonicalPicking(tenantId, inboundId);
if (acceptedPdaCompletion ? !picking.resolved() : !picking.completed()) {
throw new BizException(ResultCode.CONFLICT, acceptedPdaCompletion
? "PDA实装明细尚未全部解决" : "PDA拣货复核尚未完成");
}
}
@@ -324,9 +362,12 @@ public class LoadingDeliveryService {
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
int completedTaskCount, int unresolvedCount) {
boolean completed() {
boolean resolved() {
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
&& completedTaskCount == taskCount && unresolvedCount == 0;
&& unresolvedCount == 0;
}
boolean completed() {
return resolved() && completedTaskCount == taskCount;
}
}
@@ -418,10 +459,17 @@ public class LoadingDeliveryService {
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false);
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash,
boolean resumeProcessing) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
if (resumeProcessing && "PROCESSING".equals(existing.getStatus())) return null;
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
@@ -15,6 +16,9 @@ public interface LoadingCompletedEventMapper {
@Select("""
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.customer_id AS booking_customer_id, b.customer_name,
b.estimated_port_arrival_at, b.actual_port_arrival_at,
b.estimated_warehouse_arrival_at,
i.account_id, i.inbound_id AS source_inbound_id,
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
o.order_id AS source_order_id, o.order_no AS source_order_no,
@@ -49,7 +53,8 @@ public interface LoadingCompletedEventMapper {
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no,
a.picked_package_count AS allocated_package_count,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.package_count, i.product_quantity,
label.box_code
@@ -79,7 +84,10 @@ public interface LoadingCompletedEventMapper {
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
private Long warehouseId; private String warehouseName; private Long accountId;
private Long warehouseId; private String warehouseName; private Long bookingCustomerId;
private String customerName; private Long accountId;
private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
private Long loadingPlanItemId;
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
@@ -95,7 +103,8 @@ public interface LoadingCompletedEventMapper {
@Getter @Setter
class PalletRow {
private Long accountId; private Long sourceInboundId; private String palletCode;
private Long accountId; private Long sourceInboundId; private Long palletId;
private Long reservationId; private String palletCode;
private Integer allocationNo; private Integer allocatedPackageCount;
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
private Integer packageCount; private Integer productQuantity; private String boxCode;
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
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 LoadingCompletionCommandMapper extends BaseMapper<LoadingCompletionCommandEntity> {
@Insert("""
INSERT IGNORE INTO fl_loading_completion_command
(id,pick_task_id,operation_id,request_hash,command_status,expected_task_version,remark,
operator_id,operator_name,attempt_count,next_retry_at)
VALUES
(#{id},#{pickTaskId},#{operationId},#{requestHash},'PROCESSING',#{expectedTaskVersion},#{remark},
#{operatorId},#{operatorName},0,CURRENT_TIMESTAMP(3))
""")
int insertCommand(LoadingCompletionCommandEntity command);
@Select("SELECT * FROM fl_loading_completion_command WHERE operation_id=#{operationId}")
LoadingCompletionCommandEntity findByOperation(@Param("operationId") String operationId);
@Select("""
SELECT * FROM fl_loading_completion_command
WHERE pick_task_id=#{taskId} AND command_status IN ('PROCESSING','RETRYABLE','FAILED')
ORDER BY created_at DESC LIMIT 1
""")
LoadingCompletionCommandEntity findActiveByTask(@Param("taskId") long taskId);
@Select("SELECT * FROM fl_loading_completion_command WHERE pick_task_id=#{taskId} ORDER BY created_at DESC LIMIT 1")
LoadingCompletionCommandEntity findLatestByTask(@Param("taskId") long taskId);
@Select("""
SELECT * FROM fl_loading_completion_command
WHERE command_status IN ('PROCESSING','RETRYABLE') AND next_retry_at<=CURRENT_TIMESTAMP(3)
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
ORDER BY next_retry_at,id LIMIT #{limit}
""")
List<LoadingCompletionCommandEntity> findDue(@Param("limit") int limit);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='PROCESSING',lease_owner=#{worker},lease_until=#{leaseUntil},
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status IN ('PROCESSING','RETRYABLE')
AND next_retry_at<=CURRENT_TIMESTAMP(3)
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
""")
int claim(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int renew(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
failure_request_id=NULL,failure_trace_id=NULL
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int complete(@Param("id") long id, @Param("worker") String worker);
@Update("""
UPDATE fl_loading_completion_command
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
failure_request_id=#{requestId},failure_trace_id=#{traceId}
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
@Param("code") String code, @Param("requestId") String requestId, @Param("traceId") String traceId);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='RETRYABLE',next_retry_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL
WHERE id=#{id} AND command_status='FAILED'
""")
int resume(@Param("id") long id);
@Select("""
SELECT checkpoint_stage FROM fl_loading_completion_checkpoint
WHERE command_id=#{commandId} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@Insert("""
INSERT INTO fl_loading_completion_checkpoint
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
""")
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
@Param("stage") String stage);
}
@@ -232,6 +232,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
/**
* 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
* 异步完成期间任务会在 COMPLETING/COMPLETION_FAILED 间切换,不能把任务状态当成明细未完成事实。
*/
@Select("""
SELECT COUNT(*)
@@ -244,7 +245,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = plan_item.package_count)
@@ -19,6 +19,29 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
+ "AND task_status='LOADING' AND version=#{expectedVersion}")
int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1
WHERE id=#{taskId} AND task_status='LOADING' AND version=#{expectedVersion}
""")
int acceptCompletion(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_loading_pick_task
SET task_status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
completed_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{taskId} AND task_status='COMPLETING'
""")
int completeAccepted(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETION_FAILED',version=version+1 "
+ "WHERE id=#{taskId} AND task_status='COMPLETING'")
int failCompletion(@Param("taskId") long taskId);
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1 "
+ "WHERE id=#{taskId} AND task_status='COMPLETION_FAILED'")
int resumeCompletion(@Param("taskId") long taskId);
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
@@ -60,6 +83,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
booking.merged_into_container_booking_id AS mergedIntoContainerBookingId,
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'
WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'
WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'
WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'
WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0
THEN 'AWAITING_REVIEW'
@@ -72,7 +97,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE'
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class LoadingCompletionCommandRepository {
@Resource private LoadingCompletionCommandMapper mapper;
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
public LoadingCompletionCommandEntity latest(long taskId) { return mapper.findLatestByTask(taskId); }
public List<LoadingCompletionCommandEntity> due(int limit) { return mapper.findDue(limit); }
public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; }
public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; }
public boolean complete(long id, String worker) { return mapper.complete(id, worker) == 1; }
public boolean fail(long id, String worker, String status, LocalDateTime nextRetryAt, String stage,
String code, String requestId, String traceId) {
return mapper.fail(id, worker, status, nextRetryAt, stage, code, requestId, traceId) == 1;
}
public boolean resume(long id) { return mapper.resume(id) == 1; }
public String checkpoint(long commandId, long tenantId, long inboundId) {
return mapper.checkpoint(commandId, tenantId, inboundId);
}
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
}
}
@@ -188,7 +188,7 @@ public class LoadingListRepository {
private List<History> histories(long planId) {
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getOperationId(), row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
row.getOccurredAt())).toList();
}
@@ -57,6 +57,18 @@ public class LoadingPickRepository {
return tasks.touch(taskId, expectedVersion);
}
public int acceptCompletion(long taskId, int expectedVersion) {
return tasks.acceptCompletion(taskId, expectedVersion);
}
public int completeAccepted(long taskId, long operatorId, String operatorName) {
return tasks.completeAccepted(taskId, operatorId, operatorName);
}
public int failCompletion(long taskId) { return tasks.failCompletion(taskId); }
public int resumeCompletion(long taskId) { return tasks.resumeCompletion(taskId); }
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
@@ -37,7 +37,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService {
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateCustomerSnapshots(rows);
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
@@ -67,7 +68,8 @@ public class LoadingCompletedOutboxService {
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
@@ -102,12 +104,38 @@ public class LoadingCompletedOutboxService {
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
destinationType(sorted));
}
/** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */
private static Integer actualPalletCount(List<PalletRow> palletRows) {
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
.filter(Objects::nonNull).filter(value -> value > 0).collect(java.util.stream.Collectors.toSet());
return palletIds.isEmpty() ? null : palletIds.size();
}
private void validateCustomerSnapshots(List<Row> rows) {
for (Row row : rows) {
if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) {
throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件");
}
if (row.getCustomerName() == null || row.getCustomerName().isBlank()) {
throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件");
}
}
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> {
Set<String> names = values.stream().map(Row::getCustomerName).map(String::trim)
.collect(java.util.stream.Collectors.toSet());
if (names.size() != 1) {
throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照");
}
});
}
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
@@ -256,6 +284,7 @@ public class LoadingCompletedOutboxService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
private static String iso(java.time.LocalDateTime value) { return value == null ? null : value.toString(); }
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
/** Lease-based recovery worker for PDA completion commands. */
@Service
public class LoadingCompletionWorker {
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
private static final int MAX_ATTEMPTS = 5;
private final String workerId = "pda-completion-" + UUID.randomUUID();
@Resource private LoadingCompletionCommandRepository commands;
@Resource private LoadingPickRepository picks;
@Resource private LoadingPickService loadingPicks;
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
public void dispatch() {
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
continue;
}
try {
loadingPicks.processCompletion(candidate, workerId);
} catch (RuntimeException exception) {
handleFailure(candidate, exception);
}
}
}
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
&& exception.getCause() != null ? exception.getCause() : exception;
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
? staged.stage() : "UNEXPECTED";
String code = cause instanceof BizException business
? business.getResultCode().code() : "S0500";
String requestId = cause instanceof PdaPickSettlementService.SettlementFailure remote
? remote.requestId() : null;
String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote
? remote.traceId() : null;
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
boolean terminal = attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
stage, code, requestId, traceId);
if (terminal && recorded) {
picks.failCompletion(command.getPickTaskId());
}
log.warn("pda_completion_worker_failure commandId={} taskId={} operationId={} stage={} code={} "
+ "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(),
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
}
}
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
@@ -39,6 +41,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource;
@@ -66,6 +69,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
@@ -76,6 +80,8 @@ public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private LoadingCompletionCommandRepository completionCommands;
@Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private OceanBookingRepository oceanBookingRepository;
@@ -440,9 +446,31 @@ public class LoadingPickService {
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingCompletionCommandEntity replay = completionCommands.operation(operationId);
if (replay != null) {
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
if ("FAILED".equals(replay.getCommandStatus())) {
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试");
}
}
return view(requiredTask(taskId), null);
}
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
LoadingCompletionCommandEntity concurrentReplay = completionCommands.operation(operationId);
if (concurrentReplay != null) {
requireSameCompletionCommand(concurrentReplay, taskId, requestHash, operatorId);
return view(task, null);
}
LoadingCompletionCommandEntity active = completionCommands.active(taskId);
if (active != null) {
throw conflict("已有其他装柜完成命令正在处理");
}
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务正在完成或已经完成");
}
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
@@ -454,40 +482,122 @@ public class LoadingPickService {
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.toList();
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0);
List<LoadingPickItemMapper.InboundOwner> owners = discoveredOwners.stream()
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(IdWorker.getId());
command.setPickTaskId(taskId);
command.setOperationId(operationId);
command.setRequestHash(requestHash);
command.setExpectedTaskVersion(request.expectedTaskVersion());
command.setRemark(remark);
command.setOperatorId(operatorId);
command.setOperatorName(trim(operatorName));
if (completionCommands.insert(command) != 1) {
LoadingCompletionCommandEntity concurrent = completionCommands.operation(operationId);
requireSameCompletionCommand(concurrent, taskId, requestHash, operatorId);
return view(requiredTask(taskId), null);
}
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
return view(requiredTask(taskId), null);
}
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList();
for (LoadingPickItemMapper.InboundOwner owner : owners) {
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
completionStage(taskId, "完成前业务门禁", () ->
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId()));
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
long evidenceInboundId = owners.get(0).inboundId();
for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "READY")) {
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId(), evidenceInboundId));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "READY");
}
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
}
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(), evidenceInboundId, command.getOperatorId(),
command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(),
command.getRemark()));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
"INBOUND_COMPLETED");
}
}
completionStage(taskId, "拆单与库位结算", () ->
settlementService.settle(taskId, operatorId, operatorName));
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
}
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
throw conflict("PDA任务已经复核");
if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(),
command.getOperatorName(), command.getRemark()) != 1) {
throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核"));
}
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
completionStage(taskId, "装柜计划完成", () ->
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
repository.syncInboundProjections(taskId, operatorId, operatorName);
for (LoadingPickItemMapper.InboundOwner owner : owners) {
completionStage(taskId, "入库单状态完成", () ->
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId(), operatorId, operatorName,
"PDA-" + operationId + '-' + owner.inboundId(), remark));
if (repository.operation(command.getOperationId()) == null) {
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
command.getOperatorId(), command.getOperatorName());
}
completionStage(taskId, "完成事件发布", () ->
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
command.getOperatorId(), command.getOperatorName()));
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
requireCompletionLease(command.getId(), worker);
LoadingPickTaskEntity current = requiredTask(taskId);
if (!"COMPLETED".equals(current.getTaskStatus())
&& repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) {
throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化"));
}
if (!completionCommands.complete(command.getId(), worker)) {
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
}
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
private void requireCompletionLease(long commandId, String worker) {
if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) {
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
}
}
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
String requestHash, long operatorId) {
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|| !Long.valueOf(operatorId).equals(command.getOperatorId())
|| !requestHash.equals(command.getRequestHash())) {
throw conflict("operationId已用于不同装柜完成命令");
}
}
private static boolean checkpointAtLeast(String checkpoint, String target) {
List<String> stages = List.of("READY", "SETTLED", "INBOUND_COMPLETED");
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
}
private static void runCompletionStage(String stage, Runnable action) {
try {
action.run();
} catch (RuntimeException exception) {
throw new CompletionProcessingException(stage, exception);
}
}
public static final class CompletionProcessingException extends RuntimeException {
private final String stage;
CompletionProcessingException(String stage, RuntimeException cause) { super(cause); this.stage = stage; }
public String stage() { return stage; }
}
private void completionStage(long taskId, String stage, Runnable action) {
@@ -692,6 +802,13 @@ public class LoadingPickService {
}
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
var booking = repository.activeBookingByTask(task.getId());
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
}
List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
@@ -714,24 +831,28 @@ public class LoadingPickService {
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING"
: "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
var booking = repository.activeBookingByTask(task.getId());
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
var owners = repository.inboundOwners(task.getId());
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
var owner = detailInboundId == null ? null : owners.stream()
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
? null : repository.inbound(owner.tenantId(), owner.inboundId());
LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus())
? null : completionCommands.latest(task.getId());
CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(),
command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(),
command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(),
command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt());
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
inbound == null ? null : inbound.version(), task.getCabinetNo(),
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
workflowStatus, task.getVersion(), assignment, review, items);
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
}
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
@@ -18,6 +18,8 @@ import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Comparator;
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
@Service
public class PdaPickSettlementService {
private static final Logger log = LoggerFactory.getLogger(PdaPickSettlementService.class);
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@@ -82,6 +85,35 @@ public class PdaPickSettlementService {
}
}
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
@Transactional
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
if (!picks.allTaskItemsResolved(taskId)) {
throw conflict("PDA任务仍有未处理明细");
}
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
Integer pickedPackageCount = pickedByInbound.get(inboundId);
if (pickedPackageCount == null) {
throw conflict("PDA任务不包含指定入库单");
}
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (Long planItemId : resolution.skippedPlanItemIds()) {
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
if (changed != 0 && changed != 1) {
throw conflict("跳单明细已被并发修改,请刷新后重试");
}
}
boolean newlySplit = settleInbound(inboundId, pickedPackageCount, taskItems, taskId, operatorId, operatorName);
if (newlySplit) {
for (PdaPickSettlementPlan.SplitLine line : resolution.partialByInbound()
.getOrDefault(inboundId, List.of())) {
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
"部分拣货装柜明细");
}
}
}
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
@@ -379,7 +411,7 @@ public class PdaPickSettlementService {
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
.sorted().toList();
.distinct().sorted().toList();
}
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
@@ -422,18 +454,51 @@ public class PdaPickSettlementService {
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
releasedReservationIds, remainingItems));
} catch (FeignException exception) {
log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} "
+ "httpStatus={} releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems));
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
}
if (response == null || !response.successful() || response.data() == null
|| response.data().released() == null || response.data().transferred() == null
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} "
+ "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER "
+ "releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
response == null ? null : response.code(), response == null ? null : response.requestId(),
response == null ? null : response.traceId(), releasedReservationIds,
settlementTransferFacts(remainingItems));
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new BizException(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message());
throw new SettlementFailure(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
}
}
/** Carries only correlation facts needed by the durable command diagnosis record. */
public static final class SettlementFailure extends BizException {
private final String requestId;
private final String traceId;
SettlementFailure(ResultCode code, String message, String requestId, String traceId) {
super(code, message);
this.requestId = requestId;
this.traceId = traceId;
}
public String requestId() { return requestId; }
public String traceId() { return traceId; }
}
private static List<String> settlementTransferFacts(
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.sorted().toList();
}
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
@@ -564,7 +564,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -577,9 +577,52 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED',
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
remark VARCHAR(500) NULL COMMENT '完成备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间',
lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者',
lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间',
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码',
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
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),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_loading_completion_operation (operation_id),
KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until),
CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id),
CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
command_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
owner_sequence INT UNSIGNED NOT NULL,
checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED',
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 (command_id,tenant_id,inbound_id),
KEY idx_loading_completion_owner (command_id,owner_sequence),
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id)
REFERENCES fl_loading_completion_command(id),
CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
@@ -819,7 +862,6 @@ BEGIN
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
@@ -1018,8 +1060,10 @@ BEGIN
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
END IF;
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法';
END IF;
END$$
DELIMITER ;
@@ -427,10 +427,11 @@ components:
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
OperationHistory:
type: object
required: [historyId, actionType, operatorId, occurredAt]
required: [historyId, operationId, actionType, operatorId, occurredAt]
properties:
historyId: {$ref: '#/components/schemas/LongId'}
itemId: {$ref: '#/components/schemas/NullableLongId'}
operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重}
actionType: {type: string, description: 操作类型}
fromStatus: {type: string, nullable: true, description: 操作前状态}
toStatus: {type: string, nullable: true, description: 操作后状态}
@@ -106,13 +106,13 @@ paths:
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
post:
summary: PDA拣货全部处理后人工确认装柜完成
description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。
description: 短事务校验任务版本、全部明细和operationId请求摘要,持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实。后台worker按稳定入库单顺序以checkpoint恢复Order、Warehouse、入库状态、计划和事件;请求事务内不调用任何远程服务。同operationId重放立即返回相同PROCESSING或最终结果;不同operationId在处理期间返回A0409。
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
responses:
'200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409}
'200': {description: 已可靠受理;taskStatus=COMPLETING且workflowStatus=PROCESSING,或幂等返回最终COMPLETED结果;冲突返回A0409}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
get:
summary: Read immutable task-level claim, assignment and review audit records
@@ -139,8 +139,9 @@ components:
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity}
recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan}
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history}
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]}
completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true}
version: {type: integer, minimum: 0}
assignment: {type: object, nullable: true}
review: {type: object, nullable: true}
@@ -181,8 +182,8 @@ components:
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task}
warehouseId: {type: integer, format: int64}
warehouseName: {type: string, nullable: true}
taskStatus: {type: string, enum: [LOADING]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW}
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED], description: Server projection aligned with task detail; completion acceptance is returned immediately as PROCESSING}
version: {type: integer, minimum: 0}
assignmentVersion: {type: integer, minimum: 0}
itemCount: {type: integer, minimum: 1}
@@ -236,6 +237,22 @@ components:
expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
remark: {type: string, maxLength: 500}
CompletionCommand:
type: object
required: [id, operationId, status, expectedTaskVersion, attemptCount]
properties:
id: {type: integer, format: int64}
operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity}
status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]}
expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version}
remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark}
attemptCount: {type: integer, minimum: 0}
nextRetryAt: {type: string, format: date-time, nullable: true}
failureStage: {type: string, nullable: true}
failureCode: {type: string, nullable: true}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
CompleteLoadingRequest:
type: object
required: [expectedTaskVersion, operationId]
@@ -180,7 +180,7 @@ class LoadingListControllerContractTest {
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
"containerNo", "voyageNo", "expectedVersion", "operationReason");
@@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest {
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
.contains("Task detail is served only from the ACTIVE container booking")
.contains("Enforced by the server-side pending-task query")
.contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW")
.contains("completion acceptance is returned immediately as PROCESSING")
.contains("taskStatus=COMPLETING且workflowStatus=PROCESSING")
.contains("CompleteLoadingRequest");
String taskLookup = yaml.substring(
@@ -25,6 +25,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -195,6 +196,65 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() {
readyForPdaFinalizeFacts();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
AtomicReference<String> hash = new AtomicReference<>();
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("pda-resume"), anyString())).thenAnswer(invocation -> {
hash.set(invocation.getArgument(5));
return false;
});
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume"))
.thenAnswer(invocation -> processingOperation(hash.get()));
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-resume", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-resume"), anyString());
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-already-loaded", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString());
}
@Test
void pdaFinalizeEntryPointsAreTransactional() throws Exception {
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
}
@Test
@@ -213,6 +273,8 @@ class LoadingDeliveryServiceTest {
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 21);
verify(repository).completeLoadedChild(10, 20, 7, 2);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
@@ -350,6 +412,24 @@ class LoadingDeliveryServiceTest {
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
}
@Test
void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001");
existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE");
when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing);
when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing);
when(repository.listEvidence(10, 20)).thenReturn(List.of(existing));
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo",
new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null));
assertThat(result.evidences().evidences()).hasSize(1);
verify(repository, never()).createEvidence(any());
verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong());
}
@Test
void voidsPhotoWithReasonAndKeepsTheRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -418,11 +498,16 @@ class LoadingDeliveryServiceTest {
private void readyForPdaFinalize() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
readyForPdaFinalizeFacts();
}
private void readyForPdaFinalizeFacts() {
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
// completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet.
when(progress.getCompletedTaskCount()).thenReturn(0);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
@@ -444,4 +529,11 @@ class LoadingDeliveryServiceTest {
value.setResultJson(resultJson);
return value;
}
private static LoadingOperationEntity processingOperation(String requestHash) {
LoadingOperationEntity value = new LoadingOperationEntity();
value.setRequestHash(requestHash);
value.setStatus("PROCESSING");
return value;
}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionSqlContractTest {
@Test
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
"phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8));
assertContract(Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8));
}
private static void assertContract(String sql) {
assertThat(sql)
.contains("fl_loading_completion_command")
.contains("fl_loading_completion_checkpoint")
.contains("uk_loading_completion_operation")
.contains("idx_loading_completion_dispatch")
.contains("PROCESSING","RETRYABLE","FAILED","COMPLETED")
.contains("COMPLETING","COMPLETION_FAILED")
.contains("READY","SETTLED","INBOUND_COMPLETED")
.contains("lease_owner","lease_until","next_retry_at")
.contains("failure_stage","failure_code","failure_request_id","failure_trace_id");
}
}
@@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest {
.contains("plan_item.loading_plan_id = #{planId}")
.contains("plan_item.deleted = 0")
.contains("pick_task.loading_plan_id = #{planId}")
.contains("pick_task.task_status = 'LOADING'")
.doesNotContain("pick_task.task_status")
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.picked_package_count = plan_item.package_count")
.contains("pick_item.item_status = 'PARTIAL'")
@@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest {
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("task.task_status = 'LOADING'")
.contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')")
.contains("booking.record_status = 'ACTIVE'")
.contains("booking.source_type AS sourceType")
.contains("booking.record_status AS recordStatus")
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
.contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'")
.contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'")
.contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
.contains("THEN 'AWAITING_REVIEW'")
@@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest {
}
private static void assertLoadingCompletionTrigger(String sql) {
assertThat(sql)
int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update");
int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart);
assertThat(triggerStart).isGreaterThanOrEqualTo(0);
assertThat(triggerEnd).isGreaterThan(triggerStart);
String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd);
assertThat(beforeUpdateTrigger)
.contains("AND NOT EXISTS (")
.contains("pick_task.loading_plan_id = OLD.id")
.contains("pick_task.task_status = 'LOADING'")
.doesNotContain("pick_task.task_status")
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.item_status = 'PARTIAL'")
.contains("pick_item.picked_package_count > 0")
@@ -308,7 +313,7 @@ class SmartLoadingSqlContractTest {
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
.contains("cabinet_no VARCHAR(64) NOT NULL")
.contains("container_no VARCHAR(64) NULL")
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
@@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest {
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"),
pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"),
pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
@@ -59,6 +63,15 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11");
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20);
assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2");
assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4");
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@@ -72,6 +85,22 @@ class LoadingCompletedOutboxServiceTest {
.hasMessageContaining("缺少 SKU");
}
@Test void rejectsMissingTrustedBookingCustomerName() {
Row value = row(11, 100, 101);
value.setCustomerName(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
}
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
Row value = row(11, 100, 101);
value.setBookingCustomerId(22L);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
}
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("FBA");
@@ -118,6 +147,41 @@ class LoadingCompletedOutboxServiceTest {
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
}
@Test void immutableCustomerNameParticipatesInPayloadHash() {
Row value = row(11, 100, 101);
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
value.setCustomerName("客户甲-更名快照");
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
assertThat(captor.getAllValues().get(0).getPayloadHash())
.isNotEqualTo(captor.getAllValues().get(1).getPayloadHash());
}
@Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception {
Row value = row(11, 100, 101);
value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null);
value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null);
value.setEstimatedWarehouseArrivalAt(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(snapshots.palletRows(90L)).thenReturn(List.of());
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue();
assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue();
}
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
Row first = row(11, 100, 101); first.setPackageCount(1);
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
@@ -144,7 +208,11 @@ class LoadingCompletedOutboxServiceTest {
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'");
.contains("o.process_status = 'LOADED'")
.contains("b.customer_id AS booking_customer_id")
.contains("b.customer_name")
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
.contains("b.estimated_warehouse_arrival_at");
for (String sql : List.of(rowsSql, palletsSql)) {
assertThat(sql)
.contains("actual_pick.loading_plan_item_id = i.id")
@@ -158,6 +226,7 @@ class LoadingCompletedOutboxServiceTest {
.doesNotContain("'PICKING'");
}
assertThat(palletsSql)
.contains("a.pallet_id", "a.reservation_id")
.contains("label.tenant_id = i.account_id")
.contains("label.inbound_detail_id = i.inbound_detail_id")
.contains("label.batch_status = 'ACTIVE'")
@@ -219,20 +288,26 @@ class LoadingCompletedOutboxServiceTest {
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
r.setDestinationType("OVERSEAS");
r.setFulfillmentType("STANDARD");
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2"));
r.setVolumeCbm(BigDecimal.ONE); return r;
}
private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated,
long detail,String sku,int packages,int quantity,String boxCode) {
PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound);
row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation);
row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
@@ -0,0 +1,74 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingCompletionWorker.class)
class LoadingCompletionWorkerTest {
@MockBean private LoadingCompletionCommandRepository commands;
@MockBean private LoadingPickRepository picks;
@MockBean private LoadingPickService loadingPicks;
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
@Test
void transientFailureIsReleasedForBackoffRetry() {
LoadingCompletionCommandEntity command = command(1);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT",
new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout")))
.when(loadingPicks).processCompletion(eq(command), any(String.class));
when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)))
.thenReturn(true);
worker.dispatch();
verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null));
}
@Test
void fifthFailureBecomesTerminalAndMarksTaskFailed() {
LoadingCompletionCommandEntity command = command(4);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE",
new BizException(ResultCode.CONFLICT, "plan conflict")))
.when(loadingPicks).processCompletion(eq(command), any(String.class));
when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true);
worker.dispatch();
verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null));
verify(picks).failCompletion(20L);
}
private static LoadingCompletionCommandEntity command(int attemptCount) {
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(900L);
command.setPickTaskId(20L);
command.setOperationId("complete-1");
command.setAttemptCount(attemptCount);
return command;
}
}
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
@@ -34,6 +36,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -44,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -53,6 +57,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.clearInvocations;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class)
@@ -60,6 +65,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@@ -85,9 +92,13 @@ class LoadingPickServiceTest {
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION");
booking.setRecordStatus("ACTIVE");
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
}
@Test
@@ -232,6 +243,7 @@ class LoadingPickServiceTest {
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
@@ -414,6 +426,37 @@ class LoadingPickServiceTest {
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
verify(completionCommands, never()).latest(20L);
}
@Test
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of());
doThrow(new IllegalStateException("completion command schema unavailable"))
.when(completionCommands).latest(20L);
var result = service.task(20L, 101L);
assertThat(result.taskStatus()).isEqualTo("LOADING");
assertThat(result.completionCommand()).isNull();
verify(completionCommands, never()).latest(20L);
}
@Test
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
assertThatThrownBy(() -> service.task(20L, 999L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
verify(repository, never()).items(20L, 999L);
verify(repository, never()).cartonLabels(20L);
verify(completionCommands, never()).latest(20L);
}
@Test
@@ -488,6 +531,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -538,6 +582,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -568,6 +613,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -592,6 +638,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -613,6 +660,199 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"释放库位资源时发生并发变更"))
.when(settlementService).settle(20L, 9L, "operator");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("释放库位资源时发生并发变更");
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
Task result = service.complete(20L, new CompleteLoadingRequest(1,
"complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c",
"PDA确认装柜完成"), 9L, "operator");
assertThat(result.id()).isEqualTo(20L);
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).insert(org.mockito.ArgumentMatchers.argThat(
(LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType())));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() {
LoadingPickTaskEntity loading = task();
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
completing.setVersion(1);
AtomicReference<com.baiyue.firstleg.entity.LoadingCompletionCommandEntity> accepted = new AtomicReference<>();
when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get());
doAnswer(invocation -> {
accepted.set(invocation.getArgument(0));
return 1;
}).when(completionCommands).insert(any());
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task first = service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator");
verify(repository).taskForUpdate(20L);
clearInvocations(repository);
Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () ->
service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"));
assertThat(first.taskStatus()).isEqualTo("COMPLETING");
assertThat(first.workflowStatus()).isEqualTo("PROCESSING");
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
verify(repository, never()).taskForUpdate(20L);
verify(completionCommands, times(1)).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void differentOperationIdIsRejectedImmediatelyWithConflict() {
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
LoadingPickTaskEntity loading = task();
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
active.setOperationId("already-processing");
when(completionCommands.active(20L)).thenReturn(active);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令");
verify(completionCommands, never()).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() {
LoadingPickTaskEntity failed = task();
failed.setTaskStatus("COMPLETION_FAILED");
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
var command = completionCommand(902L, "resume-same-operation");
command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070");
command.setCommandStatus("FAILED");
command.setExpectedTaskVersion(0);
when(completionCommands.operation("resume-same-operation")).thenReturn(command);
when(completionCommands.resume(902L)).thenReturn(true);
when(repository.taskForUpdate(20L)).thenReturn(failed);
when(repository.resumeCompletion(20L)).thenReturn(1);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task result = service.complete(20L,
new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator");
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
verify(completionCommands).resume(902L);
verify(repository).resumeCompletion(20L);
verify(completionCommands, never()).insert(any());
}
@Test
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
List<LoadingPickItemMapper.InboundOwner> owners = java.util.stream.IntStream.range(0, 15)
.mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList();
when(repository.inboundOwners(20L)).thenReturn(owners);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.review(20L)).thenReturn(null);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(900L, "worker-1")).thenReturn(true);
var command = completionCommand(900L, "async-15");
service.processCompletion(command, "worker-1");
ArgumentCaptor<Long> inboundIds = ArgumentCaptor.forClass(Long.class);
verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator"));
assertThat(inboundIds.getAllValues()).containsExactly(
1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L,
1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L);
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).completeAccepted(20L, 9L, "operator");
verify(completionCommands).complete(900L, "worker-1");
}
@Test
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(101L, 60L)));
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED");
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(901L, "worker-2")).thenReturn(true);
service.processCompletion(completionCommand(901L, "async-resume"), "worker-2");
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
anyLong(), anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
"PDA-async-resume-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(
operation -> "COMPLETE_LOADING".equals(operation.getActionType())));
}
@Test
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
LoadingPickTaskEntity task = task();
@@ -814,6 +1054,21 @@ class LoadingPickServiceTest {
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand(
long id, String operationId) {
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
command.setId(id);
command.setPickTaskId(20L);
command.setOperationId(operationId);
command.setRequestHash("hash");
command.setCommandStatus("PROCESSING");
command.setOperatorId(9L);
command.setOperatorName("operator");
command.setRemark("checked");
command.setAttemptCount(1);
return command;
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
@@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest {
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10);
InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10);
first.setPalletId(801L);
second.setPalletId(801L);
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
List.of())));
writes(0, 20);
@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
@@ -50,12 +51,29 @@ class TaillegRealProducerAcceptanceTest {
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
eventKey));
assertEquals("真实验收客户", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertEquals("2026-09-30T12:00", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertNull(jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertEquals(1, jdbc.queryForObject(
"SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey));
assertEquals("35.0", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
}
private void seedCompletedLoadingPlan() {
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
"warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
@@ -66,12 +84,8 @@ class TaillegRealProducerAcceptanceTest {
MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3);
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D",
1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES");
seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F");
seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D");
jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," +
"container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')",
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
@@ -80,6 +94,16 @@ class TaillegRealProducerAcceptanceTest {
jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
}
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
int packages, String suffix) {
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
}
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {
jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," +
"inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",
+2 -2
View File
@@ -11,7 +11,7 @@
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;待分配柜级摘要固定为 `/api/platform/v1/last-leg/orders/container-summaries`,按 `sourceContainerBookingId` 跨客户聚合分页,不允许前端本地拼柜 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -21,7 +21,7 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
@@ -7,6 +7,7 @@ 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.dto.PendingContainerSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -71,6 +72,18 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
}
@GetMapping("/api/platform/v1/last-leg/orders/container-summaries")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "分页查询待分配柜级摘要",
description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<PageResult<PendingContainerSummary>> containerSummaries(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid LastlegOrderRequests.PendingContainerQuery query) {
platformScope(user, null);
return ApiResponse.success(orderService.pendingContainerPage(query));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
@@ -36,6 +36,19 @@ public final class LastlegOrderRequests {
}
}
public record PendingContainerQuery(
@Min(1) long page,
@Min(1) @Max(200) long size,
String containerNo,
LocalDateTime createdFrom,
LocalDateTime createdTo
) {
public PendingContainerQuery {
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,
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dto;
import java.math.BigDecimal;
/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */
public record PendingContainerSummary(
String sourceContainerBookingId,
String containerNo,
long orderCount,
long inboundCodeCount,
long customerCount,
long totalPackageCount,
long totalPalletCount,
BigDecimal totalVolumeCbm,
boolean eligible,
String ineligibleReason
) {
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
@@ -49,7 +50,19 @@ public class InboundPlanController {
+ OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertInboundPlanRequest request) {
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId()));
}
@PostMapping("/convert-container")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<java.util.List<InboundPlanResponse>> convertContainer(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ConvertContainerInboundPlanRequest request) {
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可按整柜生成入库计划");
}
return ApiResponse.success(service.convertContainer(request.sourceContainerBookingId()));
}
@GetMapping("/{planNo}")
@@ -118,4 +131,12 @@ public class InboundPlanController {
}
return String.valueOf(user.tenantId());
}
private String convertScope(AuthenticatedUser user, String requestedCustomer) {
if (user != null && "PLATFORM".equals(user.appCode()) && user.tenantId() != null && user.tenantId() == 0L
&& (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer.trim()))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
}
return scope(user, requestedCustomer);
}
}
@@ -0,0 +1,10 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** 平台按来源头程柜预订身份批量生成入库计划。 */
public record ConvertContainerInboundPlanRequest(
@NotBlank @Size(max = 128) String sourceContainerBookingId
) {
}
@@ -1,9 +1,10 @@
package com.baiyue.lastleg.inboundplan.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record ConvertInboundPlanRequest(
@NotNull Long orderId,
String customerId
@Size(max = 128) String customerId
) {
}
@@ -12,6 +12,12 @@ import java.util.List;
@Mapper
public interface InboundPlanOrderMapper {
record ContainerOrderRef(String customerId, Long orderId, String currentStatus) { }
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus " +
"FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<ContainerOrderRef> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@@ -150,6 +150,9 @@ public class ReceivingContainerService {
requireSessionWarehouse(active, overseasWarehouseId);
if (active != null) {
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
warehouse.requireActiveSharedWarehouse(overseasWarehouseId);
claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse);
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
}
@@ -253,7 +256,13 @@ public class ReceivingContainerService {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
WarehouseResourceGateway.WarehousePalletReference pallet;
try {
pallet = warehouse.resolvePallet(request.palletCode());
} catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found");
}
if (pallet == null) throw notFound("Pallet was not found");
validatePallet(ref, pallet);
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
@@ -727,8 +736,7 @@ public class ReceivingContainerService {
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
.anyMatch(plan -> plan.getOverseasWarehouseId() == null))) {
if (assigned.size() > 1) {
throw conflict("Container plans have inconsistent receiving warehouse assignments");
}
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
@@ -31,6 +31,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.ArrayList;
import java.util.stream.Collectors;
@Service
@@ -59,6 +60,9 @@ public class InboundPlanService {
@Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户");
}
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
@@ -70,7 +74,16 @@ public class InboundPlanService {
return detail(customerId, existing);
}
validateOrders(orders);
return createPlan(customerId, selectedOrderId, orders);
}
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders) {
String containerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound order container number is empty");
}
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
@@ -118,6 +131,56 @@ public class InboundPlanService {
return detail(customerId, plan);
}
/** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */
@Transactional
public List<InboundPlanResponse> convertContainer(String sourceContainerBookingId) {
String bookingId = trimToNull(sourceContainerBookingId);
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
List<InboundPlan> existingPlans = refs.stream()
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
.toList();
long existingCount = existingPlans.stream().filter(Objects::nonNull).count();
if (existingCount == refs.size()) {
List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
}
return List.copyOf(replay);
}
if (existingCount != 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划");
}
if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus())
|| "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单");
}
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
}
validateOrders(orders);
String currentContainerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) containerNo = currentContainerNo;
if (!Objects.equals(containerNo, currentContainerNo)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
}
snapshots.add(orders);
}
List<InboundPlanResponse> created = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index);
created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index)));
}
return List.copyOf(created);
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -2,13 +2,47 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
String PENDING_CONTAINER_FILTER = " FROM ll_order o " +
"WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id&lt;&gt;'' " +
"<if test='q.containerNo != null and q.containerNo != \"\"'>AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') </if>" +
"<if test='q.createdFrom != null'>AND o.created_at &gt;=#{q.createdFrom} </if>" +
"<if test='q.createdTo != null'>AND o.created_at &lt;=#{q.createdTo} </if>" +
"GROUP BY o.source_container_booking_id " +
"HAVING SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)&gt;0 ";
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(DISTINCT NULLIF(o.container_no,''))=1 THEN MAX(NULLIF(o.container_no,'')) ELSE NULL END AS containerNo," +
"COUNT(*) AS orderCount,COUNT(DISTINCT o.inbound_code) AS inboundCodeCount," +
"COUNT(DISTINCT o.customer_id) AS customerCount,COALESCE(SUM(o.package_count),0) AS totalPackageCount," +
"COALESCE(SUM(o.pallet_count),0) AS totalPalletCount,COALESCE(SUM(o.volume_cbm),0) AS totalVolumeCbm," +
"CASE WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)=0 " +
"AND COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
"THEN TRUE ELSE FALSE END AS eligible," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))&lt;&gt;COUNT(*) " +
"OR COUNT(DISTINCT NULLIF(o.container_no,''))&lt;&gt;1 THEN '柜号缺失或不一致' " +
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)&gt;0 " +
"THEN '柜内存在已生成计划或非待分配订单' ELSE NULL END AS ineligibleReason" +
PENDING_CONTAINER_FILTER +
"ORDER BY MAX(o.created_at) DESC,o.source_container_booking_id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<PendingContainerSummary> pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query,
@Param("offset") long offset,
@Param("limit") long limit);
@Select("<script>SELECT COUNT(*) FROM (SELECT o.source_container_booking_id" +
PENDING_CONTAINER_FILTER + ") pending_containers</script>")
long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query);
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
LastlegOrder findSource(@Param("customerId") String customerId,
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.mapper;
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 java.time.LocalDate;
@Mapper
public interface LastlegOrderNoDailySequenceMapper {
@Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " +
"VALUES(#{businessDate},LAST_INSERT_ID(1)) " +
"ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int nextValue(@Param("businessDate") LocalDate businessDate);
@Select("SELECT LAST_INSERT_ID()")
long currentValue();
}
@@ -1,35 +1,29 @@
package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Component
public class LastlegOrderNoGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd");
@Resource private LastlegOrderNoDailySequenceMapper sequenceMapper;
public String next() {
return "LMO" + IdWorker.getIdStr();
return next(LocalDate.now(BUSINESS_ZONE));
}
/** 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);
String next(LocalDate businessDate) {
sequenceMapper.nextValue(businessDate);
long sequence = sequenceMapper.currentValue();
if (sequence < 1 || sequence > 9999) {
throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate);
}
return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence);
}
}
@@ -6,6 +6,7 @@ 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.dto.PendingContainerSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -46,6 +47,21 @@ public class LastlegOrderService {
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
@Transactional(readOnly = true)
public PageResult<PendingContainerSummary> pendingContainerPage(
LastlegOrderRequests.PendingContainerQuery 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");
}
long total = orderMapper.countPendingContainers(query);
List<PendingContainerSummary> records = total == 0 ? List.of()
: orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
LastlegOrder order = findOwned(customerId, orderId);
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
@@ -83,7 +83,7 @@ public class LoadingCompletedConsumer {
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
payload.containerNo(), 0, envelope.handoverVersion());
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
String orderNo = orderNoGenerator.next();
JsonNode rawOrders = envelope.payload().path("orders");
for (int index = 0; index < payload.orders().size(); index++) {
var source = payload.orders().get(index);
@@ -183,6 +183,7 @@ public class LoadingCompletedConsumer {
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.setCustomerName(source.customerName());
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
@@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate (
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -94,7 +100,8 @@ CREATE TABLE IF NOT EXISTS ll_order (
UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id),
KEY idx_ll_order_group(customer_id,order_no),
KEY idx_ll_order_container(customer_id,container_no),
KEY idx_ll_order_list(customer_id,current_status,created_at)
KEY idx_ll_order_list(customer_id,current_status,created_at),
KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)
) ENGINE=InnoDB COMMENT='尾程订单主表';
CREATE TABLE IF NOT EXISTS ll_order_detail (
@@ -192,6 +199,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
ALTER TABLE ll_order COMMENT='尾程订单主表';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)'));
PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
@@ -156,6 +156,67 @@ paths:
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
/api/platform/v1/last-leg/orders/container-summaries:
get:
summary: 分页查询待分配柜级摘要
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: containerNo, in: query, schema: { type: string } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses:
'200':
description: 统一 ApiResponse 包装的柜级摘要分页
content:
application/json:
schema:
type: object
properties:
code: { type: string, enum: ['200'] }
data:
type: object
properties:
page: { type: integer, format: int64 }
size: { type: integer, format: int64 }
total: { type: integer, format: int64 }
pages: { type: integer, format: int64 }
records:
type: array
items: { $ref: '#/components/schemas/PendingContainerSummary' }
/api/platform/v1/last-leg/inbound-plans/convert:
post:
summary: 平台生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [customerId, orderId]
properties:
customerId: { type: string, pattern: '^[1-9][0-9]*$' }
orderId: { type: string, pattern: '^[1-9][0-9]*$' }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等。
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sourceContainerBookingId]
properties:
sourceContainerBookingId: { type: string, minLength: 1, maxLength: 128 }
responses: { '200': { description: 统一 ApiResponse 包装的入库计划数组 } }
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
@@ -531,6 +592,20 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
PendingContainerSummary:
type: object
required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible]
properties:
sourceContainerBookingId: { type: string }
containerNo: { type: string, nullable: true }
orderCount: { type: integer, format: int64 }
inboundCodeCount: { type: integer, format: int64 }
customerCount: { type: integer, format: int64 }
totalPackageCount: { type: integer, format: int64 }
totalPalletCount: { type: integer, format: int64 }
totalVolumeCbm: { type: number }
eligible: { type: boolean }
ineligibleReason: { type: string, nullable: true }
PdaWarehouseScan:
type: object
required: [code]
@@ -50,6 +50,24 @@ class LastlegOrderControllerTest {
verify(service).page(eq("200"), eq(query));
}
@Test
void platformOperatorCanQueryCrossCustomerContainerSummaries() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null);
controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery);
verify(service).pendingContainerPage(summaryQuery);
}
@Test
void containerSummariesRejectMerchantIdentity() {
var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null);
assertThrows(ResponseStatusException.class,
() -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery));
verify(service, never()).pendingContainerPage(summaryQuery);
}
@Test
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
EventEnvelope event = mock(EventEnvelope.class);
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
@@ -20,6 +22,7 @@ import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -57,6 +60,51 @@ class InboundPlanControllerTest {
verify(appointmentService).update("100", "APT-1", request);
}
@Test
void platformConversionRequiresAnExplicitCustomer() {
assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("平台生成入库计划必须指定客户");
verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void platformConversionPassesTheProvidedLongCustomerId() {
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L,
"193000000000200003");
controller.convert(platform(), request);
verify(service).convert("193000000000200003", 9223372036854775807L);
}
@Test
void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200");
controller.convert(customer, request);
verify(service).convert("100", 2L);
}
@Test
void platformContainerConversionUsesStableBookingIdentity() {
controller.convertContainer(platform(), new ConvertContainerInboundPlanRequest(" BOOKING-1 "));
verify(service).convertContainer(" BOOKING-1 ");
}
@Test
void merchantCannotConvertAWholeContainer() {
AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.convertContainer(customer,
new ConvertContainerInboundPlanRequest("BOOKING-1")))
.isInstanceOf(ResponseStatusException.class);
verify(service, never()).convertContainer(org.mockito.ArgumentMatchers.any());
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of("platform:overseas-warehouse:inbound-order:list"));
@@ -161,6 +161,26 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void refusesMissingCustomerScopeBeforeLookingUpAnOrder() {
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert(null, 2L));
assertEquals(400, error.getStatusCode().value());
assertEquals("平台生成入库计划必须指定客户", error.getReason());
verify(orderMapper, never()).lockOrder(any(), any());
}
@Test
void wrongCustomerCannotResolveAnOrderOutsideItsScope() {
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of());
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert("200", 2L));
assertEquals(404, error.getStatusCode().value());
verify(orderMapper).lockOrder("200", 2L);
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
@@ -171,6 +191,93 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void containerConversionPrevalidatesEveryOrderBeforeWritingIndependentPlans() throws Exception {
assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class)
.getAnnotation(Transactional.class));
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A")));
when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B")));
when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()))
.thenReturn(1);
var result = service.convertContainer(" BOOKING-1 ");
assertEquals(2, result.size());
assertEquals(1L, result.get(0).sourceOrderId());
assertEquals(2L, result.get(1).sourceOrderId());
verify(planMapper, times(2)).insert(any(InboundPlan.class));
verify(orderMapper).lockContainerOrders("BOOKING-1");
verify(orderMapper).lockOrder("100", 1L);
verify(orderMapper).lockOrder("200", 2L);
}
@Test
void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT")));
when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A")));
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2",
"PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0)));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
}
@Test
void completeContainerReplayReturnsStableExistingPlansWithoutWriting() {
var refs = List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"),
new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED"));
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs);
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2"));
var result = service.convertContainer("BOOKING-1");
assertEquals(List.of("LIP-1", "LIP-2"), result.stream().map(value -> value.planNo()).toList());
verify(orderMapper, never()).lockOrder(any(), any());
verify(planMapper, never()).insert(any(InboundPlan.class));
}
@Test
void partialExistingPlansAreRejectedBeforeAnyWrite() {
when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"),
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT")));
when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1"));
assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1"));
verify(orderMapper, never()).lockOrder(any(), any());
verify(planMapper, never()).insert(any(InboundPlan.class));
}
private InboundPlan existingPlan(String customerId, long orderId, String planNo) {
InboundPlan plan = new InboundPlan();
plan.setId(orderId + 100);
plan.setPlanNo(planNo);
plan.setCustomerId(customerId);
plan.setSourceOrderId(orderId);
plan.setContainerNo("CONT-1");
plan.setStatus("PENDING_ASSIGNMENT");
plan.setLineCount(1);
plan.setPalletCount(1);
plan.setTotalVolumeCbm(BigDecimal.ONE);
return plan;
}
private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) {
return new SourceOrderSnapshot(id, customerId, "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0);
}
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(),
@@ -1,31 +1,56 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import org.apache.ibatis.annotations.Insert;
import org.junit.jupiter.api.BeforeEach;
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.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
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.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
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(" "));
@Mock LastlegOrderNoDailySequenceMapper sequenceMapper;
private LastlegOrderNoGenerator generator;
@BeforeEach
void setUp() {
generator = new LastlegOrderNoGenerator();
ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper);
}
@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(); }
@Test
void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() {
LocalDate date = LocalDate.of(2026, 8, 27);
when(sequenceMapper.currentValue()).thenReturn(1L, 2L);
assertEquals("LMO-260827-0001", generator.next(date));
assertEquals("LMO-260827-0002", generator.next(date));
verify(sequenceMapper, times(2)).nextValue(date);
}
@Test
void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception {
Insert insert = LastlegOrderNoDailySequenceMapper.class
.getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class);
assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE"));
assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)"));
}
@Test
void rejectsDailySequenceBeyondFourDigits() {
when(sequenceMapper.currentValue()).thenReturn(10_000L);
assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27)));
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
@@ -19,6 +20,8 @@ import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -133,6 +136,33 @@ class LastlegOrderServiceTest {
assertEquals(25, page.getValue().getSize());
}
@Test
void pendingContainerPageUsesServerSideAggregatePagination() {
var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null);
var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3,
150, 0, new BigDecimal("12.5000"), true, null);
when(orders.countPendingContainers(query)).thenReturn(11L);
when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row));
var result = service.pendingContainerPage(query);
assertEquals(2, result.page());
assertEquals(10, result.size());
assertEquals(11, result.total());
assertEquals(2, result.pages());
assertEquals(List.of(row), result.records());
}
@Test
void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() {
var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null,
LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0));
assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query));
verify(orders, never()).countPendingContainers(any());
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);
@@ -2,9 +2,9 @@ package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; 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 LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); 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 createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); 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 createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); 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.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
@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)); }
@@ -17,14 +17,14 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@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)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2);
assertEquals(1,service.consume(event));
@@ -112,6 +112,6 @@ class LoadingCompletedConsumerTest {
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
}
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); }
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),"100","Customer 100","FIRST_LEG",List.of()),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),"100","Customer 100","FIRST_LEG",List.of()))); 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); }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PendingContainerSummaryContractTest {
@Test
void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception {
assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount",
"customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible",
"ineligibleReason"),
Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList());
Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers",
com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class)
.getAnnotation(Select.class);
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("group by o.source_container_booking_id"));
assertTrue(sql.contains("pending_assignment"));
assertTrue(sql.contains("destination_assigned"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
}
@Test
void openApiAndSchemaExposeStableContainerIdentity() throws Exception {
String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:"));
assertTrue(openApi.contains("PendingContainerSummary:"));
assertTrue(openApi.contains("sourceContainerBookingId:"));
assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)"));
}
}
@@ -44,6 +44,9 @@ class PrintStatelessArchitectureTest {
@Test
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
ClassLoader loader = getClass().getClassLoader();
assertThat(isPresent("org.springframework.dao.CannotAcquireLockException", loader))
.as("common-web exception handling requires Spring DAO exceptions at runtime")
.isTrue();
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
.as("Print must exclude the JDBC starter inherited through common-core")
.isFalse();
@@ -0,0 +1,52 @@
export const CONTAINER_SUMMARY_ENDPOINT = '/api/platform/v1/last-leg/orders/container-summaries'
export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert-container'
export const CONTAINER_CONVERT_EXPECTED_ORDERS = 15
export function groupOrdersByCustomer(orders) {
if (!Array.isArray(orders)) return new Map()
const result = new Map()
for (const order of orders) {
const customerId = String(order?.customerId ?? '').trim()
if (!customerId) return new Map()
result.set(customerId, [...(result.get(customerId) || []), order])
}
return result
}
export function validContainerBatchResult(result, sourceOrders) {
const plans = Array.isArray(result) ? result : []
if (!Array.isArray(sourceOrders) || !sourceOrders.length || plans.length !== sourceOrders.length) return false
const sourceIds = new Set(sourceOrders.map((order) => String(order.orderId)))
const resultIds = plans.flatMap((plan) => Array.isArray(plan?.lines) ? plan.lines : [])
.map((line) => String(line?.orderId))
return resultIds.length === sourceIds.size
&& new Set(resultIds).size === sourceIds.size
&& resultIds.every((orderId) => sourceIds.has(orderId))
&& plans.every((plan) => {
const lines = Array.isArray(plan?.lines) ? plan.lines : []
const expected = sourceOrders.find((order) => String(order.orderId) === String(lines[0]?.orderId))
return plan?.status === 'PENDING_ASSIGNMENT'
&& lines.length === 1 && expected != null
&& String(plan?.customerId ?? '') === String(expected.customerId)
&& String(plan?.planNo ?? '').length > 0
&& Array.isArray(plan?.pallets) && Array.isArray(plan?.allocations)
})
}
export function validContainerSummaryPage(data, expected) {
if (!Array.isArray(data?.records)) return false
const row = data.records.find((item) => String(item?.sourceContainerBookingId ?? '')
=== String(expected?.sourceContainerBookingId ?? ''))
return Number(data.page) >= 1 && Number(data.size) >= 1 && Number(data.total) >= 1
&& Number(data.pages) >= 1 && String(row?.containerNo ?? '') === String(expected?.containerNo ?? '')
&& Number(row?.orderCount) === Number(expected?.orderCount)
&& Number(row?.inboundCodeCount) === Number(expected?.inboundCodeCount)
}
export function zeroDrift(before, after) {
return JSON.stringify(before) === JSON.stringify(after)
}
export function stableReplay(first, replay) {
return zeroDrift(first, replay)
}
@@ -0,0 +1,49 @@
export const EVENT_BRIDGE_RECOVERY_FIXTURE = Object.freeze({
customerCode: 'C10000000',
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
initialOutboxStatus: 'PENDING',
initialRetryCount: 62,
failedTarget: '172.31.198.8:9313',
currentOutboxStatus: 'PENDING',
currentRetryCount: 81,
currentFailureCode: 'A0401',
currentFailureMessage: '内部服务令牌无效',
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
dockerDnsProven: true,
})
export const TEST_EVENT_BRIDGE_URLS = Object.freeze({
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
})
export const INTERNAL_TOKEN_COHORT_SERVICES = Object.freeze([
'firstleg',
'event-bridge-cn',
'event-bridge-overseas',
'lastleg-service',
])
export function validTestEventBridgeEnvironment(values = {}) {
return Object.entries(TEST_EVENT_BRIDGE_URLS)
.every(([key, expected]) => values[key] === expected)
&& Object.values(values).every((value) => !/^https?:\/\/(?:\d{1,3}\.){3}\d{1,3}(?::|\/|$)/.test(String(value)))
}
// Callers must provide only a non-secret fingerprint/revision marker, never the token value.
export function validInternalTokenCohort(fingerprints = {}) {
const values = INTERNAL_TOKEN_COHORT_SERVICES.map((service) => fingerprints[service])
return values.every((value) => typeof value === 'string' && value.length > 0)
&& new Set(values).size === 1
}
export function recoveredEventBridgeFacts(facts = {}) {
return facts.firstlegOutboxStatus === 'SENT'
&& Number(facts.overseasBridgeEventCount) === 1
&& Number(facts.lastlegInboxEventCount) === 1
&& Number(facts.lastlegOrderCount) === 1
&& Number(facts.cnBridgeDuplicateCount) === 0
&& Number(facts.manualReplayCount) === 0
&& Number(facts.sqlMutationCount) === 0
}
@@ -4,6 +4,7 @@ import test from 'node:test'
import {
confirmedNetSummary,
normalizeWeightPalletOptionPage,
normalizedGrossKg,
palletOptionsPath,
selectWeightPallet,
@@ -50,10 +51,13 @@ test('HTTP scenario uses pallet option and net weight contracts without legacy f
assert.match(optionsPath, /keyword=TP-09/)
assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50)
assert.deepEqual(weightPalletOptionRecords({
records: [{ palletId: '1' }], page: 1, size: 50, total: 1000, pages: 20,
records: [{ palletId: '1' }], page: '1', size: '50', total: '1000', pages: '20',
}), [{ palletId: '1' }])
assert.deepEqual(normalizeWeightPalletOptionPage({
records: [], page: '1', size: '20', total: '0', pages: '0',
}), { records: [], page: 1, size: 20, total: 0, pages: 0 })
assert.equal(weightPalletOptionRecords([{ palletId: '1' }]), null)
assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000 }), null)
assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000, total: 0, pages: 0 }), null)
assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath'))
assert.match(source, /form\.append\('file'/)
assert.doesNotMatch(source, /form\.append\('palletId'/)
@@ -214,13 +214,21 @@ export function palletOptionsPath(tenantId, inboundId, keyword = '') {
return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?${params.toString()}`
}
export function weightPalletOptionRecords(page) {
export function normalizeWeightPalletOptionPage(page) {
if (!page || Array.isArray(page) || !Array.isArray(page.records)) return null
if (!Number.isInteger(Number(page.page)) || Number(page.page) < 1) return null
if (!Number.isInteger(Number(page.size)) || Number(page.size) < 1
|| Number(page.size) > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null
if (page.records.length > Number(page.size)) return null
return page.records
const normalized = Object.fromEntries(['page', 'size', 'total', 'pages']
.map((key) => [key, Number(page[key])]))
if (!Number.isInteger(normalized.page) || normalized.page < 1) return null
if (!Number.isInteger(normalized.size) || normalized.size < 1
|| normalized.size > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null
if (!Number.isInteger(normalized.total) || normalized.total < 0) return null
if (!Number.isInteger(normalized.pages) || normalized.pages < 0) return null
if (page.records.length > normalized.size) return null
return { records: page.records, ...normalized }
}
export function weightPalletOptionRecords(page) {
return normalizeWeightPalletOptionPage(page)?.records ?? null
}
async function createUploadedResult(context, target, pallet, image, overrides = {}) {
@@ -401,7 +409,12 @@ export async function run(context) {
)
const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse,
`inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS)
const palletOptions = weightPalletOptionRecords(palletOptionsPage)
const normalizedPalletOptionsPage = normalizeWeightPalletOptionPage(palletOptionsPage)
const palletOptions = normalizedPalletOptionsPage?.records ?? null
context.assert(MODULE, '托盘候选成功响应为正式分页对象且数字字符串可归一化', palletOptionsResponse,
normalizedPalletOptionsPage !== null,
`page=${palletOptionsPage?.page ?? 'missing'}, size=${palletOptionsPage?.size ?? 'missing'}, total=${palletOptionsPage?.total ?? 'missing'}, pages=${palletOptionsPage?.pages ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0
&& palletOptions.every(palletSnapshotComplete)
context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse,
@@ -0,0 +1,123 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const loadingPickService = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java')
const completionCommand = read('../../firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingCompletionCommandEntity.java')
const completionWorker = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java')
const loadingPickTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java')
const settlementTests = read('../../firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java')
const unifiedSql = read('../../deploy/sql/头程数据库统一增量-MySQL8.sql')
const finalizeHttp = read('phase3-loading-finalize.mjs')
const pdaPage = read('../../../apps/pda-uniapp/pages/picking/picking.vue')
const pdaCompletionState = read('../../../apps/pda-uniapp/utils/loading-completion-state.js')
const pdaCompletionTests = read('../../../apps/pda-uniapp/tests/loading-completion-state.test.mjs')
const RECOVERY_TASK_ID = '2092817774453780481'
function retryDecision({ operationStatus, sameRequest, finalTaskStatus }) {
if (!sameRequest) return 'CONFLICT'
if (operationStatus === 'PROCESSING') return 'PROCESSING'
if (operationStatus === 'COMPLETED') return 'REPLAY'
if (finalTaskStatus === 'COMPLETED') return 'RECONCILE_COMPLETE'
return 'RESUME'
}
test('offline orchestration model distinguishes processing replay recovery and conflict', () => {
assert.equal(retryDecision({ operationStatus: 'PROCESSING', sameRequest: true }), 'PROCESSING')
assert.equal(retryDecision({ operationStatus: 'COMPLETED', sameRequest: true }), 'REPLAY')
assert.equal(retryDecision({ operationStatus: 'FAILED', sameRequest: true, finalTaskStatus: 'LOADING' }), 'RESUME')
assert.equal(retryDecision({ operationStatus: 'FAILED', sameRequest: false }), 'CONFLICT')
})
test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => {
const start = loadingPickService.indexOf('public Task complete(')
const end = loadingPickService.indexOf('/** Called only by the completion worker', start)
const block = loadingPickService.slice(start, end)
assert.match(block, /LoadingCompletionCommandEntity/)
assert.match(block, /acceptCompletion/)
assert.match(block, /return view\(requiredTask\(taskId\), null\)/)
assert.doesNotMatch(block, /settlementService|completeAfterPdaSettlement|publishCompletedEvent/)
assert.match(loadingPickService, /"COMPLETING"\.equals\(task\.getTaskStatus\(\)\)[\s\S]{0,80}"PROCESSING"/)
})
test('complete does not hold fl_loading_pick_task FOR UPDATE across remote settlement calls', () => {
const start = loadingPickService.indexOf('public void processCompletion(')
const end = loadingPickService.indexOf('private static void requireSameCompletionCommand', start)
const block = loadingPickService.slice(start, end)
assert.notEqual(start, -1)
assert.match(block, /settlementService\.settleOwner/)
assert.doesNotMatch(block, /requiredLoadingTaskForUpdate|requiredTaskForUpdate/)
assert.match(loadingPickService, /completionCommands\.insert/)
})
test('completion operation has durable PROCESSING and terminal response state', () => {
assert.match(completionCommand, /commandStatus/)
assert.match(completionCommand, /attemptCount/)
assert.match(completionCommand, /nextRetryAt/)
assert.match(completionCommand, /completedAt/)
assert.match(completionCommand, /failureStage/)
const commandTable = unifiedSql.slice(unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command'),
unifiedSql.indexOf(';', unifiedSql.indexOf('CREATE TABLE IF NOT EXISTS fl_loading_completion_command')) + 1)
assert.match(commandTable, /command_status/i)
assert.match(commandTable, /next_retry_at/i)
assert.match(commandTable, /completed_at/i)
assert.match(unifiedSql, /CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint/)
})
test('durable worker processes inbound settlements incrementally with retry backoff', () => {
assert.match(completionWorker, /@Scheduled/)
assert.match(completionWorker, /commands\.due/)
assert.match(completionWorker, /commands\.claim/)
assert.match(completionWorker, /15L <<|nextRetryAt|retryAt/)
assert.match(completionWorker, /"FAILED"\s*:\s*"RETRYABLE"/)
assert.match(loadingPickService, /checkpointAtLeast[\s\S]{0,1800}SETTLED/)
assert.match(loadingPickTests, /(?:transient|瞬时)[\s\S]{0,1500}(?:backoff|retry|退避)/i)
assert.match(loadingPickTests, /FAILED[\s\S]{0,1500}(?:same operationId|同operationId|resume|恢复)/i)
})
test('same operationId concurrency returns PROCESSING promptly instead of waiting for the task row lock', () => {
assert.match(loadingPickTests, /sameOperationId[\s\S]{0,1600}PROCESSING/i)
assert.match(loadingPickTests, /(?:assertTimeout|timeout|elapsed|millis|seconds)[\s\S]{0,500}(?:CannotAcquireLock|FOR UPDATE|taskForUpdate)/i)
assert.match(loadingPickTests, /never\(\)[\s\S]{0,180}requiredLoadingTaskForUpdate|never\(\)[\s\S]{0,180}taskForUpdate/i)
})
test('a different operationId is rejected while the completion command is processing', () => {
assert.match(loadingPickTests, /(?:different operationId|不同operationId|different key|不同键)[\s\S]{0,1200}(?:A0409|CONFLICT)/i)
})
test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => {
assert.match(settlementTests, /15|fifteen/i)
assert.match(settlementTests, /(?:partial success|部分成功|local rollback|本地回滚)/i)
assert.match(settlementTests, /(?:settlementTarget|COMPLETED|replaySettlement)/)
assert.match(loadingPickTests, /(?:resume|recover|reconcile|恢复|核平)[\s\S]{0,2000}COMPLETE_LOADING/i)
})
test('recovery fixture preserves the observed fifteen-settlement partial success', () => {
assert.equal(RECOVERY_TASK_ID, '2092817774453780481')
assert.match(finalizeHttp, new RegExp(RECOVERY_TASK_ID))
assert.match(finalizeHttp, /15[\s\S]{0,600}(?:Warehouse|settlement)/i)
assert.match(finalizeHttp, /(?:LOADING|v1)[\s\S]{0,600}(?:恢复|recovery|resume)/i)
})
test('final acceptance reconciles task allocation reservation occupancy and one completion audit', () => {
for (const fact of ['task', 'allocation', 'reservation', 'occupancy']) {
assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing final ${fact} reconciliation`)
}
assert.match(finalizeHttp, /COMPLETE_LOADING/)
assert.match(finalizeHttp, /(?:length === 1|恰有一次|只写一条)/)
assert.match(finalizeHttp, /(?:PROCESSING|处理中)/)
assert.match(finalizeHttp, /(?:elapsed|latency|耗时|毫秒|ms)/i)
})
test('PDA treats accepted completion as processing, returns to list, and never claims completion early', () => {
assert.match(pdaCompletionState, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/)
assert.match(pdaCompletionTests, /accepted completion is processing/)
assert.match(pdaPage, /loading-completion-state/)
assert.match(pdaPage, /(?:PROCESSING|COMPLETING)[\s\S]{0,1200}navigateBackOr/)
assert.doesNotMatch(pdaPage, /completeContainerPickingTask[\s\S]{0,700}showToast\('已确认装柜完成'/,
'accepted PROCESSING response must not show a completed success toast')
assert.match(pdaPage, /shouldReconcileCompletion/)
})
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const locationService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java')
const locationRepository = read('../../warehouse/src/main/java/com/baiyue/warehouse/repository/LocationRepository.java')
const locationMapper = read('../../warehouse/src/main/java/com/baiyue/warehouse/mapper/LocationOccupancyMapper.java')
const locationTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java')
const settlementService = read('../../warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java')
const settlementTests = read('../../warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java')
const finalizeHttp = read('phase3-loading-finalize.mjs')
function releaseDecision({ sharedOverflow, locationStatus, activeOccupanciesAfterRelease }) {
if (sharedOverflow) return 'SHARED_STATUS_UNCHANGED'
if (activeOccupanciesAfterRelease > 0) return 'KEEP_OCCUPIED'
if (locationStatus === 'AVAILABLE') return 'ALREADY_AVAILABLE'
if (locationStatus === 'OCCUPIED') return 'TRANSITION_TO_AVAILABLE'
return 'CONFLICT'
}
test('offline release decision converges active-occupancy/location drift', () => {
assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'AVAILABLE', activeOccupanciesAfterRelease: 0 }),
'ALREADY_AVAILABLE')
assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'OCCUPIED', activeOccupanciesAfterRelease: 0 }),
'TRANSITION_TO_AVAILABLE')
assert.equal(releaseDecision({ sharedOverflow: false, locationStatus: 'OCCUPIED', activeOccupanciesAfterRelease: 1 }),
'KEEP_OCCUPIED')
assert.equal(releaseDecision({ sharedOverflow: true, locationStatus: 'AVAILABLE', activeOccupanciesAfterRelease: 5 }),
'SHARED_STATUS_UNCHANGED')
})
test('releaseSelectedReservations accepts AVAILABLE drift only after the last active occupancy is released', () => {
const block = locationService.slice(locationService.indexOf('releaseSelectedReservations('),
locationService.indexOf('transferToChild(', locationService.indexOf('releaseSelectedReservations(')))
assert.match(block, /releaseOccupancy/)
assert.match(block, /(?:count|has|exists|remaining)[A-Za-z]*(?:Active|Occupied|Occupanc)/i)
assert.match(block, /"AVAILABLE"\.equals\(location\.getStatus\(\)\)|location\.getStatus\(\).*"AVAILABLE"/s)
assert.match(block, /"OCCUPIED"/)
assert.match(block, /changeStatus/)
assert.doesNotMatch(block,
/releaseOccupancy[\s\S]{0,400}changeStatus\([^;]+"OCCUPIED",\s*"AVAILABLE"[^;]+\)\s*!=\s*1\)\s*\{/,
'release must not unconditionally require OCCUPIED -> AVAILABLE after occupancy release')
})
test('repository can count or lock remaining active occupancies for one location', () => {
assert.match(locationRepository, /(?:count|has|exists|lock)[A-Za-z]*(?:Active|Occupied|Occupanc)[A-Za-z]*\(/i)
assert.match(locationMapper, /occupancy_status\s*=\s*'OCCUPIED'/i)
assert.match(locationMapper, /location_id/)
})
test('Warehouse unit tests lock all three non-shared convergence cases', () => {
assert.match(locationTests, /AVAILABLE[\s\S]{0,1200}(?:active occupancy|活动占用|drift|漂移)/i)
assert.match(locationTests, /(?:last|最后)[\s\S]{0,1200}(?:AVAILABLE|changeStatus)/i)
assert.match(locationTests, /(?:other|remaining|其他|仍有)[\s\S]{0,1200}(?:occupancy|占用)[\s\S]{0,600}(?:never\(\)\.changeStatus|OCCUPIED)/i)
})
test('same settlement key replays without a second release or transfer', () => {
assert.match(settlementService, /"COMPLETED"\.equals\(operation\.getOperationStatus\(\)\)[\s\S]{0,180}replaySettlementResult/)
assert.match(settlementTests, /shouldReplayCompletedPdaSettlementForSameIdempotencyKey/)
assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.releaseSelectedReservations/)
assert.match(settlementTests, /verify\(locations,\s*never\(\)\)\.transferToChildByReservation/)
})
test('HTTP failure contract proves task plan allocation location history outbox operation zero drift', () => {
for (const fact of ['task', 'plan', 'inbound', 'allocation', 'location', 'history', 'outbox', 'operation']) {
assert.match(finalizeHttp, new RegExp(fact, 'i'), `missing ${fact} failure baseline`)
}
assert.match(finalizeHttp, /A0409/)
assert.match(finalizeHttp, /零写入|零漂移/)
})
@@ -0,0 +1,130 @@
import {
CONTAINER_CONVERT_ENDPOINT,
CONTAINER_CONVERT_EXPECTED_ORDERS,
CONTAINER_SUMMARY_ENDPOINT,
validContainerBatchResult,
validContainerSummaryPage,
zeroDrift,
} from './container-inbound-plan-batch-contract.mjs'
const MODULE = 'phase4-container-inbound-plan-batch'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
const id = (value) => value == null ? '' : String(value)
const records = (data) => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : []
const success = (response) => response?.http === 200 && String(response?.body?.code) === '200'
const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
const rejected = (response, codes) => response?.http === 200 && codes.includes(String(response?.body?.code))
function gap(context, name, reason, priority = 'P1') {
context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: true })
context.addStep(MODULE, name, null, false, `${priority}: ${reason}`, OPTIONS)
throw new Error(`${priority} blocking coverage gap: ${name}: ${reason}`)
}
function fixture(context) {
const value = context.createdResources?.phase4ContainerInboundPlanFixture
if (value?.marker !== context.marker || !value?.normal?.containerNo || !value?.normal?.sourceContainerBookingId
|| !Array.isArray(value.normal.orders) || value.normal.orders.length !== CONTAINER_CONVERT_EXPECTED_ORDERS) {
gap(context, '整柜15票新数据前置', '缺少本轮marker的15票同柜正常fixture,禁止借用历史已转换数据')
}
for (const name of ['invalid', 'partial']) {
if (!value[name]?.containerNo || !Array.isArray(value[name]?.orders)) {
gap(context, `${name}整柜前置`, `缺少本轮${name}专用fixture,不能验证整批零漂移`, 'P1')
}
}
return value
}
async function listOrders(context, containerNo) {
const response = await context.requestOverseas('GET',
`/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`)
return { response, rows: records(response?.body?.data).filter((row) => row?.containerNo === containerNo) }
}
async function snapshot(context, source) {
const listed = await listOrders(context, source.containerNo)
const plans = []
for (const order of listed.rows) {
if (!order?.inboundCode) continue
const response = await context.requestOverseas('GET',
`/api/platform/v1/last-leg/inbound-plans?page=1&size=200&inboundCode=${encodeURIComponent(order.inboundCode)}`)
plans.push(...records(response?.body?.data).map((row) => ({
planId: id(row.planId), planNo: row.planNo, orderId: id(row.orderId), customerId: id(row.customerId), status: row.status,
})))
}
return {
orders: listed.rows.map((row) => ({
id: id(row.id), customerId: id(row.customerId), currentStatus: row.currentStatus,
inboundOrderNo: row.inboundOrderNo ?? null, palletCount: row.palletCount ?? null,
})).sort((a, b) => a.id.localeCompare(b.id)),
plans: plans.sort((a, b) => `${a.planId}:${a.orderId}`.localeCompare(`${b.planId}:${b.orderId}`)),
}
}
function convertBody(source) {
return { sourceContainerBookingId: id(source.sourceContainerBookingId) }
}
async function convert(context, name, source, key) {
const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT,
convertBody(source), { 'Idempotency-Key': key }, context.overseasToken)
context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS)
return response
}
export async function run(context) {
const data = fixture(context)
const summaryResponse = await context.requestOverseas('GET',
`${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`)
context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse,
success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, {
sourceContainerBookingId: data.normal.sourceContainerBookingId,
containerNo: data.normal.containerNo,
orderCount: CONTAINER_CONVERT_EXPECTED_ORDERS,
inboundCodeCount: CONTAINER_CONVERT_EXPECTED_ORDERS,
}), `orderPageSize=10, expectedOrderCount=15; ${trace(summaryResponse)}`, OPTIONS)
const normalList = await listOrders(context, data.normal.containerNo)
context.assert(MODULE, 'P1同柜15票列表前置', normalList.response,
success(normalList.response) && normalList.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS
&& normalList.rows.every((row) => typeof row.id === 'string' && typeof row.customerId === 'string'),
`orders=${normalList.rows.length}; ${trace(normalList.response)}`, OPTIONS)
const invalidBefore = await snapshot(context, data.invalid)
const invalid = await convert(context, 'P1任一非法票整柜转换拒绝', data.invalid, `${context.marker}-invalid-container`)
const invalidAfter = await snapshot(context, data.invalid)
context.assert(MODULE, 'P1非法票整批零漂移', invalid,
rejected(invalid, ['A0400', 'A0409']) && zeroDrift(invalidBefore, invalidAfter),
trace(invalid), OPTIONS)
const partialBefore = await snapshot(context, data.partial)
const partial = await convert(context, 'P1部分已转换整柜冲突', data.partial, `${context.marker}-partial-container`)
const partialAfter = await snapshot(context, data.partial)
context.assert(MODULE, 'P1部分转换冲突零漂移', partial,
rejected(partial, ['A0409']) && zeroDrift(partialBefore, partialAfter), trace(partial), OPTIONS)
const key = `${context.marker}-container-convert`
const first = await convert(context, 'P1整柜一次转换并逐订单生成独立计划', data.normal, key)
const result = first?.body?.data
context.assert(MODULE, 'P1整柜响应覆盖全部订单和独立计划', first,
success(first) && validContainerBatchResult(result, data.normal.orders), trace(first), OPTIONS)
const replay = await convert(context, 'P1同键同请求重放', data.normal, key)
context.assert(MODULE, 'P1重放计划号与明细零新增', replay,
success(replay) && zeroDrift(result, replay?.body?.data), trace(replay), OPTIONS)
const concurrentKey = `${context.marker}-container-double-click`
const concurrent = await Promise.all([
convert(context, 'P1并发双击A', data.normal, concurrentKey),
convert(context, 'P1并发双击B', data.normal, concurrentKey),
])
context.assert(MODULE, 'P1并发双击收敛同一批计划', concurrent[1],
concurrent.every(success) && zeroDrift(concurrent[0].body?.data, concurrent[1].body?.data),
concurrent.map(trace).join('; '), OPTIONS)
const after = await listOrders(context, data.normal.containerNo)
context.assert(MODULE, 'P1全部15票推进INBOUND_PLANNED', after.response,
success(after.response) && after.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS
&& after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo),
`planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS)
}
@@ -0,0 +1,47 @@
export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
status: 'PROCESSED',
payloadMutable: false,
reusableForAcceptance: false,
})
export const TAILLEG_ORDER_NUMBER = Object.freeze({
pattern: /^LMO-\d{6}-\d{4}$/,
timeZone: 'Asia/Shanghai',
sharedBy: 'containerNo',
})
export function validOwnerSnapshots(orders, expectedCount = orders?.length) {
if (!Array.isArray(orders) || orders.length !== expectedCount || orders.length === 0) return false
const owners = new Set()
for (const order of orders) {
const customerId = String(order?.customerId ?? '').trim()
const customerName = String(order?.customerName ?? '').trim()
if (!customerId || !customerName) return false
owners.add(`${customerId}\u0000${customerName}`)
}
return owners.size === 1
}
export function validContainerOrderGroup(rows) {
if (!Array.isArray(rows) || rows.length === 0) return false
const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim()))
const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim()))
const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim())
return containerNos.size === 1 && !containerNos.has('')
&& orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0])
&& sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length
}
export function validEventReplay(before, after) {
return Number(before?.orderCount) === Number(after?.orderCount)
&& String(before?.orderNo ?? '') === String(after?.orderNo ?? '')
&& Number(before?.detailCount) === Number(after?.detailCount)
&& Number(before?.historyCount) === Number(after?.historyCount)
}
export function adminPreservesOrderNumber(apiValue, displayedValue, copiedValue) {
return TAILLEG_ORDER_NUMBER.pattern.test(String(apiValue ?? ''))
&& displayedValue === apiValue
&& copiedValue === apiValue
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
CONTAINER_CONVERT_ENDPOINT,
CONTAINER_CONVERT_EXPECTED_ORDERS,
CONTAINER_SUMMARY_ENDPOINT,
groupOrdersByCustomer,
stableReplay,
validContainerBatchResult,
validContainerSummaryPage,
zeroDrift,
} from './container-inbound-plan-batch-contract.mjs'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const scenario = read('phase4-container-inbound-plan-batch.mjs')
const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java')
const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java')
const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue')
function orders() {
return Array.from({ length: 15 }, (_, index) => ({
orderId: String(9_000 + index), customerId: index < 10 ? '101' : '202',
packageCount: 2, volumeCbm: '1.2500', productQuantity: 20,
palletCount: 1, volumeWeightKg: '18.750',
estimatedArrivalAt: '2026-09-01T08:30:00', actualArrivalAt: null, estimatedWarehouseAt: null,
}))
}
test('semantic contract creates one independent plan per order even for the same customer', () => {
const source = orders()
assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15)
assert.equal(groupOrdersByCustomer(source).size, 2)
const result = source.map((order, index) => ({
customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT',
lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}],
}))
assert.equal(validContainerBatchResult(result, source), true)
})
test('server container summary remains complete when the order page would contain only ten rows', () => {
assert.equal(CONTAINER_SUMMARY_ENDPOINT, '/api/platform/v1/last-leg/orders/container-summaries')
assert.equal(validContainerSummaryPage({
records: [{ sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: '15', inboundCodeCount: '15' }],
page: '1', size: '10', total: '1', pages: '1',
}, { sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: 15, inboundCodeCount: 15 }), true)
})
test('atomic failure and replay helpers reject drift', () => {
const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] }
assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true)
assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true)
})
test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => {
assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert-container')
for (const marker of ['P1整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝',
'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突',
'P1服务端柜级摘要跨订单分页仍返回15票']) {
assert.match(scenario, new RegExp(marker))
}
assert.match(scenario, /Promise\.all/)
assert.match(scenario, /Idempotency-Key/)
assert.match(scenario, /requestId/)
assert.match(scenario, /traceId/)
})
test('container API uses one stable long string identity', () => {
const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`),
openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`)))
assert.match(endpoint, /required: \[sourceContainerBookingId\]/)
assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/)
assert.match(controller, /convertContainer/)
assert.match(adminApi, /sourceContainerBookingId: string/)
assert.match(adminView, /sourceContainerBookingId: row\.sourceContainerBookingId/)
})
test('backend batch conversion is one transaction and never merges same-customer orders', () => {
assert.match(controller, /ApiResponse<[^>]*(?:Batch|List|Container)[^>]*>/)
assert.match(service, /@Transactional[\s\S]{0,120}(?:convertContainer|convertBatch|convert)/)
assert.match(service, /(?:List<InboundPlanResponse>|Container.*Response|Batch.*Response)/)
assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/)
assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/)
assert.match(service, /来源柜存在不能转换的尾程订单/)
assert.match(service, /HttpStatus\.CONFLICT/)
})
test('Admin consumes a server-paged container summary and never derives 15 from the current order page', () => {
assert.match(adminApi, /(?:listLastMileContainerSummaries|listContainerSummaries)/)
assert.match(adminApi, /container-summaries/)
assert.match(adminView, /(?:listLastMileContainerSummaries|listContainerSummaries)\(/)
assert.match(adminView, /containerRows\.value\s*=\s*result\.records/)
assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/)
})
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
EVENT_BRIDGE_RECOVERY_FIXTURE,
INTERNAL_TOKEN_COHORT_SERVICES,
recoveredEventBridgeFacts,
TEST_EVENT_BRIDGE_URLS,
validInternalTokenCohort,
validTestEventBridgeEnvironment,
} from './event-bridge-pending-recovery.mjs'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const cnCompose = read('../../deploy/docker-compose.yml')
const overseasCompose = read('../../deploy/docker-compose.overseas.yml')
const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh')
const deployServices = read('../../deploy/scripts/deploy-services.sh')
const crossRegionHttp = read('phase4-cross-region-events.mjs')
test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => {
assert.deepEqual(TEST_EVENT_BRIDGE_URLS, {
LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312',
FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312',
})
assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true)
assert.equal(validTestEventBridgeEnvironment({
...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313',
}), false)
})
test('Compose wires both producers to the stable service aliases used by test env', () => {
assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/)
assert.match(overseasCompose,
/FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/)
assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/)
assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/)
for (const source of [cnCompose, overseasCompose]) {
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/)
assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/)
}
})
test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => {
assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [
'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service',
])
assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
assert.match(overseasCompose,
/x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/)
assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/)
assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/)
assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/)
assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/)
for (const source of [deployBuilt, deployServices]) {
assert.match(source, /--env-file\s+"\$ENV_FILE"/)
}
assert.equal(validInternalTokenCohort({
firstleg: 'revision-20260827',
'event-bridge-cn': 'revision-20260827',
'event-bridge-overseas': 'revision-20260827',
'lastleg-service': 'revision-20260827',
}), true)
assert.equal(validInternalTokenCohort({
firstleg: 'revision-20260827',
'event-bridge-cn': 'revision-20260827',
'event-bridge-overseas': 'stale-revision',
'lastleg-service': 'revision-20260827',
}), false)
})
test('incident fixture records DNS recovery followed by retry81 token rejection', () => {
assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, {
customerCode: 'C10000000',
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
initialOutboxStatus: 'PENDING',
initialRetryCount: 62,
failedTarget: '172.31.198.8:9313',
currentOutboxStatus: 'PENDING',
currentRetryCount: 81,
currentFailureCode: 'A0401',
currentFailureMessage: '内部服务令牌无效',
currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704',
currentTraceId: 'e8e6d620c1cff40caa765e6968f89024',
dockerDnsProven: true,
})
})
test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => {
assert.equal(recoveredEventBridgeFacts({
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
manualReplayCount: 0, sqlMutationCount: 0,
}), true)
assert.equal(recoveredEventBridgeFacts({
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0,
manualReplayCount: 0, sqlMutationCount: 0,
}), false)
assert.equal(recoveredEventBridgeFacts({
firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1,
lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0,
manualReplayCount: 1, sqlMutationCount: 0,
}), false)
assert.match(crossRegionHttp, /const delivered = await poll/)
assert.match(crossRegionHttp, /deliveredRows\.length > 0/)
assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/)
})
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
adminPreservesOrderNumber,
IMMUTABLE_TAILLEG_EVENT_FIXTURE,
TAILLEG_ORDER_NUMBER,
validContainerOrderGroup,
validEventReplay,
validOwnerSnapshots,
} from './tailleg-owner-order-number-contract.mjs'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java')
const producer = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java')
const producerRows = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java')
const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java')
const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java')
const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java')
const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java')
const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java')
const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java')
const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
const adminPresentation = read('../../../apps/admin-web/src/modules/overseas-warehouse/lastMilePresentation.ts')
const adminList = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue')
const adminDetail = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')
test('new 15-order event requires one non-empty trusted owner snapshot', () => {
const orders = Array.from({ length: 15 }, (_, index) => ({
sourceInboundId: `inbound-${index + 1}`, customerId: 'customer-1', customerName: '验收货主',
}))
assert.equal(validOwnerSnapshots(orders, 15), true)
assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7
? { ...order, customerName: '' } : order), 15), false)
assert.equal(validOwnerSnapshots(orders.map((order, index) => index === 7
? { ...order, customerName: '另一货主' } : order), 15), false)
})
test('the processed historical event is immutable and cannot be reused for acceptance', () => {
assert.deepEqual(IMMUTABLE_TAILLEG_EVENT_FIXTURE, {
eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b',
status: 'PROCESSED', payloadMutable: false, reusableForAcceptance: false,
})
})
test('V2 producer freezes customerId and customerName and rejects a blank name', () => {
assert.match(eventV2, /List<Item> items,\s*String customerId,\s*String customerName,\s*String orderType/)
assert.match(producerRows, /b\.customer_name/)
assert.match(producer, /first\.getCustomerName\(\)/)
assert.match(producer, /(?:customerName|getCustomerName)[\s\S]{0,300}(?:isBlank|hasText)/)
})
test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => {
assert.match(consumer, /source\.customerName\(\)/)
assert.match(consumer, /customerName[\s\S]{0,240}(?:isBlank|hasText)/)
assert.match(consumer, /order\.setCustomerName\(customerName\)/)
assert.match(orderEntity, /private String customerName;/)
assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/)
})
test('Lastleg query DTO and Admin list/detail use the frozen owner name', () => {
assert.match(orderService, /o\.getCustomerName\(\)/)
assert.match(adminApi, /customerName\?: string \| null/)
assert.match(adminPresentation, /owner:\s*text\(dto\.customerName\)/)
assert.match(adminDetail, /label:\s*'货主',\s*value:\s*text\(order\?\.customerName\)/)
})
test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => {
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true)
assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false)
assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai')
assert.match(generator, /Asia\/Shanghai/)
assert.match(generator, /yyMMdd/)
assert.match(generator, /LMO-/)
assert.match(generator, /%04d|padStart\(4|String\.format/)
assert.match(generatorTests, /(?:concurrent|并发)/i)
assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i)
assert.match(generatorTests, /\^LMO-\\d\{6\}-\\d\{4\}\$/)
})
test('one container with 15 inbound rows shares one new LMO number', () => {
const rows = Array.from({ length: 15 }, (_, index) => ({
containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`,
}))
assert.equal(validContainerOrderGroup(rows), true)
assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14
? { ...row, orderNo: 'LMO-260827-0043' } : row)), false)
assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.[^(]+\(payload\.containerNo\(\)\);[\s\S]*for \(int index/)
assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource\)/)
})
test('same event replay adds nothing and preserves the allocated number', () => {
const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 }
assert.equal(validEventReplay(before, { ...before }), true)
assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false)
assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/)
assert.match(consumerTests, /(?:replay|duplicate|重复)[\s\S]{0,900}(?:orderNo|OrderNo)/i)
})
test('Admin displays and copies the exact queried number without reformatting', () => {
const value = 'LMO-260827-0042'
assert.equal(adminPreservesOrderNumber(value, value, value), true)
assert.equal(adminPreservesOrderNumber(value, value.replaceAll('-', ''), value), false)
assert.match(adminPresentation, /orderNo:\s*text\(dto\.orderNo\)/)
assert.match(adminList, /prop:\s*'orderNo'/)
assert.match(adminDetail, /reference-value="referenceValue"/)
const adminOrderSources = `${adminList}\n${adminDetail}`
assert.match(adminOrderSources, /(?:clipboard\.writeText|copyText|copyToClipboard)[\s\S]{0,240}(?:orderNo|referenceValue)|(?:orderNo|referenceValue)[\s\S]{0,240}(?:clipboard\.writeText|copyText|copyToClipboard)/)
})
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { normalizeWeightPalletOptionPage } from './inbound-weight-ocr.mjs'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java')
const pageResult = read('../../common-core/src/main/java/com/baiyue/common/core/api/PageResult.java')
const longJackson = read('../../common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.java')
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts')
const adminApiTests = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.test.ts')
const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts')
function endpointBlock(source, marker, nextMarker) {
const start = source.indexOf(marker)
assert.notEqual(start, -1, `missing marker: ${marker}`)
const end = source.indexOf(nextMarker, start + marker.length)
return source.slice(start, end === -1 ? source.length : end)
}
test('formal success accepts two records and string pagination numbers', () => {
const records = [{ palletId: '1' }, { palletId: '2' }]
assert.deepEqual(normalizeWeightPalletOptionPage({ records, page: '1', size: '20', total: '2', pages: '1' }),
{ records, page: 1, size: 20, total: 2, pages: 1 })
})
test('formal empty page remains a successful page instead of an error', () => {
assert.deepEqual(normalizeWeightPalletOptionPage({ records: [], page: '1', size: '20', total: '0', pages: '0' }),
{ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
test('malformed array and invalid page metadata are rejected by the HTTP contract', () => {
assert.equal(normalizeWeightPalletOptionPage([]), null)
assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 20, total: -1, pages: 0 }), null)
assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 101, total: 0, pages: 0 }), null)
})
test('Controller DTO and response envelope use PageResult records/page/size/total/pages', () => {
const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit')
assert.match(block, /ApiResponse<PageResult<InboundMeasurementResponses\.PalletSnapshot>>/)
assert.match(pageResult, /record PageResult<T>\(List<T> records, long page, long size, long total, long pages\)/)
})
test('OpenAPI documents the page object and permits runtime string pagination numbers', () => {
const endpoint = endpointBlock(openapi,
' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:',
' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:')
assert.match(endpoint, /data: \{\$ref: '#\/components\/schemas\/WeightPalletOptionPage'\}/)
const schema = endpointBlock(openapi, ' WeightPalletOptionPage:', ' CreateWeightResultRequest:')
assert.match(schema, /required: \[records, page, size, total, pages\]/)
for (const field of ['page', 'size', 'total', 'pages']) {
assert.match(schema, new RegExp(`${field}:[^\n]*(?:oneOf|string)`),
`${field} must permit the string form emitted by the shared Long serializer`)
}
assert.match(longJackson, /serializerByType\(Long\.TYPE, ToStringSerializer\.instance\)/)
})
test('Admin reads records, normalizes numeric strings, and never treats data as an array', () => {
const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage')
assert.match(block, /ApiEnvelope<WeightPalletOptionPageResponse>/)
assert.match(block, /records:\s*page\.records/)
assert.match(block, /normalizedPageNumber\(page\.(?:page|size|total|pages)/)
assert.doesNotMatch(block, /ApiEnvelope<WeightPalletOption\[\]>/)
assert.match(adminPanel, /palletOptions\.value = result\.records/)
})
test('Admin behavior covers two-record success and an empty successful page', () => {
assert.match(adminApiTests, /formal paged pallet response[\s\S]{0,900}records[\s\S]{0,300}page: '1'/)
assert.match(adminBehavior, /no pallet options[\s\S]{0,500}palletPage\(\[\]\)/)
})
test('Admin behavior exposes a body code business error as a candidate load failure', () => {
assert.match(adminBehavior, /getWeightPalletOptions\)\.mockRejectedValueOnce\(new ApiResponseError\([^)]*'A0500'/)
assert.match(adminBehavior, /可选托盘加载失败|托盘服务暂不可用/)
})
test('Admin behavior separately covers a non-2xx HTTP or network error', () => {
assert.match(adminBehavior, /(?:HTTP error|HTTP错误|network error|网络错误)[\s\S]{0,700}(?:AxiosError|HTTP_5|status)/i)
})
test('Admin aborts and ignores an expired candidate response', () => {
assert.match(adminBehavior, /in-flight pallet request[\s\S]{0,900}signal\?\.aborted[\s\S]{0,300}过期托盘/)
})
+25 -1
View File
@@ -232,7 +232,9 @@ Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin
$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js')
$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js')
$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js')
Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API'
Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/gateway-api'" '国内 PDA H5 同源 API'
Assert-Contains $domesticPdaVite "'/gateway-api'" '国内 PDA H5 独立代理前缀'
Assert-Contains $domesticPdaVite 'path.replace(/^\/gateway-api/, ''/api'')' '国内 PDA H5 Gateway 重写'
Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游'
Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API'
Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化'
@@ -303,6 +305,21 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" '
Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界'
# 待分配尾程订单按来源柜由服务端聚合,整柜转换每票独立计划且整批原子。
$lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
$adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts')
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约'
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用'
# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。
Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同'
Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA 库位展示合同'
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
foreach ($module in $pom.project.modules.module) {
@@ -312,6 +329,8 @@ foreach ($module in $pom.project.modules.module) {
# 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。
$criticalPaths = @(
'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts',
'apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue',
'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts',
'apps/admin-web/src/modules/overseas-warehouse/api/pallets.ts',
@@ -351,6 +370,8 @@ $criticalPaths = @(
'apps/pda-uniapp/vite.config.js',
'apps/pda-uniapp/utils/app-update.js',
'apps/pda-uniapp/tests/app-update.test.mjs',
'apps/pda-uniapp/tests/partial-layout.test.mjs',
'apps/pda-uniapp/tests/picking-location-display.test.mjs',
'apps/pda-uniapp/utils/evidence-upload-queue.js',
'apps/pda-overseas-uniapp/package.json',
'apps/pda-overseas-uniapp/src/pages.json',
@@ -423,6 +444,9 @@ $criticalPaths = @(
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java',
'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java',
'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java',
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',
@@ -109,6 +109,16 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
@Param("warehouseId") long warehouseId,
@Param("limit") int limit);
@Select("""
SELECT * FROM wh_location
WHERE id = #{id} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND deleted = 0
FOR UPDATE
""")
LocationEntity lockOwned(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("id") long id);
@Insert("""
INSERT INTO wh_location (
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
@@ -61,6 +61,16 @@ public interface LocationReservationMapper extends BaseMapper<LocationReservatio
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_reservation
SET reservation_status = 'RELEASED', released_at = CURRENT_TIMESTAMP(3),
release_reason = #{reason}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND reservation_status = 'CONFIRMED' AND version = #{version}
""")
int releaseConfirmed(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_reservation
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
@@ -44,6 +44,10 @@ public class LocationRepository {
.eq(LocationEntity::getId, id));
}
public LocationEntity lockOwned(long tenantId, long warehouseId, long id) {
return locations.lockOwned(tenantId, warehouseId, id);
}
public PageResult<LocationEntity> page(Long tenantId, PageQuery query) {
Page<LocationEntity> result = locations.selectLocationPage(new Page<>(query.page(), query.size()),
tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status());
@@ -94,6 +98,10 @@ public class LocationRepository {
|| occupancies.countActiveByLocation(tenantId, locationId) > 0;
}
public int countActiveOccupancies(long tenantId, long locationId) {
return occupancies.countActiveByLocation(tenantId, locationId);
}
public List<LocationReservationEntity> findReservationsByKeys(long tenantId, String sourceService,
Collection<String> keys) {
if (keys.isEmpty()) {
@@ -129,6 +137,10 @@ public class LocationRepository {
return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int releaseConfirmedReservation(LocationReservationEntity entity, String reason) {
return reservations.releaseConfirmed(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int transferConfirmedReservation(LocationReservationEntity entity, String sourceService,
long sourceBusinessId, long targetBusinessId,
String targetInboundCode) {
@@ -24,6 +24,8 @@ import com.baiyue.warehouse.entity.LocationReservationEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.LocationRepository;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +44,7 @@ import java.util.LinkedHashMap;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
@@ -51,6 +54,8 @@ import java.util.Set;
@Service
@Validated
public class LocationResourceService {
private static final Logger log = LoggerFactory.getLogger(LocationResourceService.class);
@Resource
private LocationRepository repository;
@Resource
@@ -165,13 +170,8 @@ public class LocationResourceService {
if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
continue;
}
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放占用时库位状态已被并发修改");
}
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
"释放占用时库位状态已被并发修改");
released.add(active(occupancy, "RELEASED", null));
}
for (LocationReservationEntity reservation : repository.findReservationsByBusiness(
@@ -237,15 +237,8 @@ public class LocationResourceService {
List<SelectiveReleaseResult> released = new ArrayList<>(selectedIndexes.size());
for (int index : selectedIndexes) {
LocationOccupancyEntity occupancy = active.get(index);
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT,
"Location occupancy changed concurrently while releasing selected locations");
}
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
"Location occupancy changed concurrently while releasing selected locations");
released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
occupancy.getLocationCode(), packages.get(index)));
@@ -289,15 +282,8 @@ public class LocationResourceService {
throw new BizException(ResultCode.CONFLICT,
"Occupied location has no valid allocated package count and cannot be released exactly");
}
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT,
"Location occupancy changed concurrently while releasing selected locations");
}
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
"Location occupancy changed concurrently while releasing selected locations");
released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
occupancy.getLocationCode(), allocatedPackageCount));
@@ -322,19 +308,94 @@ public class LocationResourceService {
LocationOccupancyEntity occupancy = active.get(index);
if (!requested.contains(occupancy.getReservationId())) continue;
Integer allocated = occupancy.getAllocatedPackageCount();
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (allocated == null || allocated < 0 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(),
occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(),
command.operatorId()) != 1)) {
if (allocated == null || allocated < 0) {
throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更");
}
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), "释放库位资源时发生并发变更");
result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated));
}
return List.copyOf(result);
}
/**
* Occupancy, confirmed reservation and the materialized location status form one resource fact.
* Lock the location before changing either fact so the final availability decision is serialized.
*/
private void releaseOccupiedResource(LocationOccupancyEntity occupancy, String reason, long operatorId,
String conflictMessage) {
LocationEntity location = repository.lockOwned(occupancy.getTenantId(), occupancy.getWarehouseId(),
occupancy.getLocationId());
LocationReservationEntity reservation = repository.lockReservation(
occupancy.getTenantId(), occupancy.getReservationId());
if (!matchesConfirmedResource(occupancy, reservation) || location == null) {
warnReleaseConflict(occupancy, location, reservation, 0, 0, -1, "RESOURCE_FACT_MISMATCH");
throw new BizException(ResultCode.CONFLICT, conflictMessage);
}
int occupancyAffected = repository.releaseOccupancy(occupancy, reason);
if (occupancyAffected != 1) {
warnReleaseConflict(occupancy, location, reservation, occupancyAffected, 0, -1,
"OCCUPANCY_UPDATE_MISSED");
throw new BizException(ResultCode.CONFLICT, conflictMessage);
}
int reservationAffected = repository.releaseConfirmedReservation(reservation, reason);
if (reservationAffected != 1) {
warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected, -1,
"RESERVATION_UPDATE_MISSED");
throw new BizException(ResultCode.CONFLICT, conflictMessage);
}
if (Boolean.TRUE.equals(location.getSharedOverflow())) {
return;
}
int remainingActive = repository.countActiveOccupancies(occupancy.getTenantId(), occupancy.getLocationId());
if (remainingActive > 0) {
warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected,
remainingActive, "NON_SHARED_LOCATION_STILL_OCCUPIED");
throw new BizException(ResultCode.CONFLICT, conflictMessage);
}
if ("AVAILABLE".equals(location.getStatus())) {
log.warn("warehouse_location_release_converged reservationId={} occupancyId={} locationId={} "
+ "expectedLocationStatus=OCCUPIED actualLocationStatus={} occupancyAffected={} "
+ "reservationAffected={} remainingActiveOccupancies={}",
occupancy.getReservationId(), occupancy.getId(), occupancy.getLocationId(), location.getStatus(),
occupancyAffected, reservationAffected, remainingActive);
return;
}
if (!"OCCUPIED".equals(location.getStatus())
|| repository.changeStatus(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), operatorId) != 1) {
warnReleaseConflict(occupancy, location, reservation, occupancyAffected, reservationAffected,
remainingActive, "LOCATION_STATUS_UPDATE_MISSED");
throw new BizException(ResultCode.CONFLICT, conflictMessage);
}
}
private static boolean matchesConfirmedResource(LocationOccupancyEntity occupancy,
LocationReservationEntity reservation) {
return reservation != null
&& "CONFIRMED".equals(reservation.getReservationStatus())
&& Objects.equals(reservation.getWarehouseId(), occupancy.getWarehouseId())
&& Objects.equals(reservation.getBusinessId(), occupancy.getBusinessId())
&& Objects.equals(reservation.getLocationId(), occupancy.getLocationId())
&& Objects.equals(reservation.getPalletId(), occupancy.getPalletId())
&& Objects.equals(reservation.getSourceService(), occupancy.getSourceService());
}
private static void warnReleaseConflict(LocationOccupancyEntity occupancy, LocationEntity location,
LocationReservationEntity reservation, int occupancyAffected,
int reservationAffected, int remainingActive, String cause) {
log.warn("warehouse_location_release_conflict cause={} reservationId={} occupancyId={} locationId={} "
+ "expectedReservationStatus=CONFIRMED actualReservationStatus={} "
+ "expectedOccupancyStatus=OCCUPIED actualOccupancyStatus={} "
+ "expectedLocationStatus=OCCUPIED actualLocationStatus={} occupancyAffected={} "
+ "reservationAffected={} remainingActiveOccupancies={}",
cause, occupancy.getReservationId(), occupancy.getId(), occupancy.getLocationId(),
reservation == null ? null : reservation.getReservationStatus(), occupancy.getOccupancyStatus(),
location == null ? null : location.getStatus(), occupancyAffected, reservationAffected, remainingActive);
}
/**
* 将未拣货的已占用库位从主入库单转给二级子入库单。
* 转移只改变业务归属和剩余箱数,不释放库位;托盘仅作为关联快照。
@@ -85,9 +85,7 @@ ON DUPLICATE KEY UPDATE
location_type=VALUES(location_type),
max_weight_kg=VALUES(max_weight_kg),
shared_overflow=VALUES(shared_overflow),
status='AVAILABLE',
remark=VALUES(remark),
deleted=0;
remark=VALUES(remark);
INSERT INTO wh_location(
id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name,
@@ -106,9 +104,7 @@ ON DUPLICATE KEY UPDATE
location_type=VALUES(location_type),
max_weight_kg=VALUES(max_weight_kg),
shared_overflow=VALUES(shared_overflow),
status='AVAILABLE',
remark=VALUES(remark),
deleted=0;
remark=VALUES(remark);
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
DELIMITER //
@@ -27,6 +27,9 @@ class InitialWarehouseResourceSeedSqlContractTest {
}
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
int locationSeedStart = sql.indexOf("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201");
int locationSeedEnd = sql.indexOf("DROP PROCEDURE IF EXISTS assert_yiwu_location_seed", locationSeedStart);
String locationSeed = sql.substring(locationSeedStart, locationSeedEnd);
assertThat(sql)
.contains("0," + yiwuId + ",'TP-001','木托盘'")
.contains("0," + dongguanId + ",'TP-002','塑料托盘'")
@@ -52,5 +55,10 @@ class InitialWarehouseResourceSeedSqlContractTest {
.contains("'AVAILABLE'")
.doesNotContain("INSERT INTO wh_location_reservation")
.doesNotContain("INSERT INTO wh_location_occupancy");
assertThat(locationSeed)
.doesNotContain("status='AVAILABLE'")
.doesNotContain("status = 'AVAILABLE'")
.doesNotContain("deleted=0")
.doesNotContain("deleted = 0");
}
}
@@ -257,9 +257,7 @@ class LocationResourceServiceTest {
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1);
when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
stubSuccessfulOccupiedRelease(active.get(1), occupiedLocation(10L, "A2"), "PDA partial pick", 7L);
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA partial pick", 10, List.of(10L), null));
@@ -280,9 +278,7 @@ class LocationResourceServiceTest {
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
second.setAllocatedPackageCount(7);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
stubSuccessfulOccupiedRelease(second, occupiedLocation(10L, "A2"), "PDA full pick", 7L);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
@@ -299,9 +295,7 @@ class LocationResourceServiceTest {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(0);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
when(repository.releaseOccupancy(occupancy, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 9L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
stubSuccessfulOccupiedRelease(occupancy, occupiedLocation(9L, "A1"), "PDA full pick", 7L);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(9L)));
@@ -323,11 +317,8 @@ class LocationResourceServiceTest {
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
when(repository.findOwned(0L, 88L, item.getLocationId()))
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
.thenReturn(1);
stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()),
"PDA default pick", 7L);
}
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
@@ -348,11 +339,8 @@ class LocationResourceServiceTest {
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active);
for (int index = 0; index < 2; index++) {
LocationOccupancyEntity item = active.get(index);
when(repository.findOwned(10001L, 88L, item.getLocationId()))
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
.thenReturn(1);
stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()),
"PDA default pick", 7L);
}
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
@@ -379,7 +367,8 @@ class LocationResourceServiceTest {
void shouldFailOnConcurrentOccupancyUpdate() {
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
when(repository.lockOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
when(repository.lockReservation(10001L, 101L)).thenReturn(confirmedReservationFor(active));
when(repository.releaseOccupancy(active, "PDA pick")).thenReturn(0);
assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand(
@@ -389,6 +378,64 @@ class LocationResourceServiceTest {
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldConvergeDriftedAvailableLocationWhenLastOccupancyIsReleasedByReservation() {
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
active.setAllocatedPackageCount(6);
LocationEntity drifted = location(9L, "A1");
drifted.setVersion(2);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
stubSuccessfulOccupiedRelease(active, drifted, "PDA complete", 7L);
var result = service.releaseSelectedReservations(new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.reservationId()).isEqualTo(101L);
assertThat(item.locationId()).isEqualTo(9L);
});
verify(repository).releaseConfirmedReservation(any(LocationReservationEntity.class), org.mockito.Mockito.eq("PDA complete"));
verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(),
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldRejectAvailableNonSharedLocationWhenAnotherOccupancyRemains() {
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
when(repository.lockOwned(10001L, 88L, 9L)).thenReturn(location(9L, "A1"));
LocationReservationEntity reservation = confirmedReservationFor(active);
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.releaseOccupancy(active, "PDA complete")).thenReturn(1);
when(repository.releaseConfirmedReservation(reservation, "PDA complete")).thenReturn(1);
when(repository.countActiveOccupancies(10001L, 9L)).thenReturn(1);
assertThatThrownBy(() -> service.releaseSelectedReservations(
new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(),
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldReleaseOneSharedOverflowAllocationWithoutOpeningOrMergingTheOther() {
LocationOccupancyEntity active = occupancy(201L, 101L, 99L, "A0", 501L);
active.setSharedOverflow(true);
active.setAllocatedPackageCount(4);
LocationEntity shared = overflow();
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
stubSuccessfulOccupiedRelease(active, shared, "PDA complete", 7L);
assertThat(service.releaseSelectedReservations(
new com.baiyue.warehouse.dto.LocationDtos.ReservationReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA complete", List.of(101L))))
.singleElement().extracting(item -> item.reservationId()).isEqualTo(101L);
verify(repository, never()).countActiveOccupancies(anyLong(), anyLong());
verify(repository, never()).changeStatus(anyLong(), anyLong(), anyLong(),
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
@@ -514,4 +561,36 @@ class LocationResourceServiceTest {
reservation.setVersion(0);
return reservation;
}
private void stubSuccessfulOccupiedRelease(LocationOccupancyEntity occupancy, LocationEntity location,
String reason, long operatorId) {
location.setTenantId(occupancy.getTenantId());
LocationReservationEntity reservation = confirmedReservationFor(occupancy);
when(repository.lockOwned(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId()))
.thenReturn(location);
when(repository.lockReservation(occupancy.getTenantId(), occupancy.getReservationId())).thenReturn(reservation);
when(repository.releaseOccupancy(occupancy, reason)).thenReturn(1);
when(repository.releaseConfirmedReservation(reservation, reason)).thenReturn(1);
if (!Boolean.TRUE.equals(location.getSharedOverflow())) {
when(repository.countActiveOccupancies(occupancy.getTenantId(), occupancy.getLocationId())).thenReturn(0);
if ("OCCUPIED".equals(location.getStatus())) {
when(repository.changeStatus(occupancy.getTenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), operatorId)).thenReturn(1);
}
}
}
private static LocationReservationEntity confirmedReservationFor(LocationOccupancyEntity occupancy) {
LocationReservationEntity reservation = reservation("CONFIRMED");
reservation.setId(occupancy.getReservationId());
reservation.setTenantId(occupancy.getTenantId());
reservation.setWarehouseId(occupancy.getWarehouseId());
reservation.setSourceService(occupancy.getSourceService());
reservation.setBusinessId(occupancy.getBusinessId());
reservation.setPalletId(occupancy.getPalletId());
reservation.setLocationId(occupancy.getLocationId());
reservation.setLocationCode(occupancy.getLocationCode());
reservation.setSharedOverflow(occupancy.getSharedOverflow());
return reservation;
}
}