Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
@@ -40,6 +40,7 @@
|
|||||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
| 海外 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` |
|
| 海外 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/` | `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 已移除 |
|
| 海外入仓分页、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/` | `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/` |
|
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||||
|
|||||||
+23
-5
@@ -7,7 +7,14 @@ import java.util.List;
|
|||||||
public record FirstLegLoadingPlanCompletedV1(
|
public record FirstLegLoadingPlanCompletedV1(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
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(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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) {
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
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;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
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,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, destinationType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-5
@@ -7,7 +7,15 @@ import java.util.List;
|
|||||||
public record FirstLegLoadingPlanCompletedV2(
|
public record FirstLegLoadingPlanCompletedV2(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
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(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -15,11 +23,18 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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 必须逐箱扫码。 */
|
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||||
String destinationType) {
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
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;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -30,11 +45,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
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,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
@@ -43,11 +60,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
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,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
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]}
|
||||||
+12
-4
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
"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",
|
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||||
String json = mapper.writeValueAsString(payload);
|
String json = mapper.writeValueAsString(payload);
|
||||||
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
"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)),
|
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||||
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
|
||||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
"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);
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-4
@@ -4,17 +4,43 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
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.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class FirstLegLoadingPlanCompletedV2Test {
|
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
|
@Test
|
||||||
void carriesTheThreeItemRemarks() throws Exception {
|
void carriesTheThreeItemRemarks() throws Exception {
|
||||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
"客户备注", "内部备注", "仓库备注");
|
"客户备注", "内部备注", "仓库备注");
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
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));
|
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||||
String json = new ObjectMapper().writeValueAsString(payload);
|
String json = new ObjectMapper().writeValueAsString(payload);
|
||||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||||
@@ -29,13 +55,14 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
"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(
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(payload);
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
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/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||||
}
|
}
|
||||||
@@ -44,7 +71,7 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
void carriesFrozenDestinationType() {
|
void carriesFrozenDestinationType() {
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
"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(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"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,
|
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
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(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"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/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-tx</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-core</artifactId>
|
<artifactId>spring-security-core</artifactId>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
|
|||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
import org.springframework.validation.FieldError;
|
import org.springframework.validation.FieldError;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
@@ -96,6 +97,14 @@ public class GlobalExceptionHandler {
|
|||||||
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||||
log.error("Unhandled request failure requestId={} traceId={}",
|
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.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
@@ -116,6 +117,18 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
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) {
|
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',
|
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
|
||||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
|
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
|
||||||
container_no VARCHAR(64) 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 BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
|
||||||
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
||||||
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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),
|
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
|
||||||
KEY idx_loading_pick_container (container_no, task_status, started_at),
|
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 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装柜拣货任务';
|
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
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
|
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
|
WHERE pick_item.loading_plan_item_id = item.id
|
||||||
AND pick_task.loading_plan_id = OLD.id
|
AND pick_task.loading_plan_id = OLD.id
|
||||||
AND pick_task.task_status = 'LOADING'
|
|
||||||
AND (
|
AND (
|
||||||
(pick_item.item_status = 'PICKED'
|
(pick_item.item_status = 'PICKED'
|
||||||
AND pick_item.picked_package_count = item.package_count)
|
AND pick_item.picked_package_count = item.package_count)
|
||||||
@@ -6473,9 +6472,7 @@ ON DUPLICATE KEY UPDATE
|
|||||||
location_type = VALUES(location_type),
|
location_type = VALUES(location_type),
|
||||||
max_weight_kg = VALUES(max_weight_kg),
|
max_weight_kg = VALUES(max_weight_kg),
|
||||||
shared_overflow = VALUES(shared_overflow),
|
shared_overflow = VALUES(shared_overflow),
|
||||||
status = 'AVAILABLE',
|
remark = VALUES(remark);
|
||||||
remark = VALUES(remark),
|
|
||||||
deleted = 0;
|
|
||||||
|
|
||||||
INSERT INTO wh_location (
|
INSERT INTO wh_location (
|
||||||
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
|
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),
|
location_type = VALUES(location_type),
|
||||||
max_weight_kg = VALUES(max_weight_kg),
|
max_weight_kg = VALUES(max_weight_kg),
|
||||||
shared_overflow = VALUES(shared_overflow),
|
shared_overflow = VALUES(shared_overflow),
|
||||||
status = 'AVAILABLE',
|
remark = VALUES(remark);
|
||||||
remark = VALUES(remark),
|
|
||||||
deleted = 0;
|
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
|
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
|
||||||
DELIMITER //
|
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_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', '开始装柜人');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by_name', '开始装柜人名称');
|
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_pick_task', 'version', '乐观锁版本号');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_at', '确认时间');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_at', '确认时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_by', '确认操作人ID');
|
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)
|
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
|
||||||
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
|
) 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();
|
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||||
SELECT 'PASS' AS migration_status,
|
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'));
|
'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;
|
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'),
|
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',
|
'SELECT 1',
|
||||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
'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(
|
public record History(
|
||||||
Long historyId,
|
Long historyId,
|
||||||
Long itemId,
|
Long itemId,
|
||||||
|
String operationId,
|
||||||
String actionType,
|
String actionType,
|
||||||
String fromStatus,
|
String fromStatus,
|
||||||
String toStatus,
|
String toStatus,
|
||||||
|
|||||||
@@ -125,12 +125,18 @@ public final class LoadingPickDtos {
|
|||||||
Long operatorId, String operatorName, LocalDateTime createdAt) {
|
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,
|
public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId,
|
||||||
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
|
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
|
||||||
String currentContainerNo, String sourceType, String recordStatus,
|
String currentContainerNo, String sourceType, String recordStatus,
|
||||||
Long mergedIntoContainerBookingId, String taskStatus,
|
Long mergedIntoContainerBookingId, String taskStatus,
|
||||||
String workflowStatus, Integer version, Assignment assignment,
|
String workflowStatus, Integer version, Assignment assignment,
|
||||||
Review review, List<PickItem> items) {
|
Review review, CompletionCommand completionCommand, List<PickItem> items) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
|
/** 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));
|
.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 void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
|
||||||
|
|
||||||
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
|
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
|
||||||
|
|||||||
+62
-14
@@ -34,6 +34,7 @@ import java.security.MessageDigest;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -74,6 +75,18 @@ public class LoadingDeliveryService {
|
|||||||
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
|
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());
|
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
|
||||||
String fileName = file.fileName();
|
String fileName = file.fileName();
|
||||||
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
|
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,
|
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
|
||||||
long operatorId, String operatorName, String key,
|
long operatorId, String operatorName, String key,
|
||||||
CompleteLoadingRequest request) {
|
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);
|
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;
|
if (replay != null) return replay;
|
||||||
Snapshot inbound = requireInbound(tenantId, inboundId);
|
Snapshot inbound = requireInbound(tenantId, inboundId);
|
||||||
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
|
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
|
||||||
if (target != null) {
|
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())) {
|
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,
|
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
|
||||||
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
|
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
|
||||||
}
|
}
|
||||||
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
|
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
|
||||||
loadingModelBridge.requireCompleted(tenantId, inboundId);
|
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||||
completionGate.requireReleasedBooking(tenantId, inboundId);
|
completionGate.requireReleasedBooking(tenantId, inboundId);
|
||||||
requireCompletedPicking(tenantId, inboundId);
|
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
|
||||||
requireCompletionEvidence(tenantId, evidenceInboundId);
|
requireCompletionEvidence(tenantId, evidenceInboundId);
|
||||||
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
|
||||||
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
|
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
|
* 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.
|
* with that current server version instead of reusing the pre-settlement task snapshot.
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
||||||
long operatorId, String operatorName,
|
long operatorId, String operatorName,
|
||||||
String key, String remark) {
|
String key, String remark) {
|
||||||
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
|
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
|
||||||
long evidenceInboundId, long operatorId,
|
long evidenceInboundId, long operatorId,
|
||||||
String operatorName, String key, String remark) {
|
String operatorName, String key, String remark) {
|
||||||
@@ -148,7 +173,7 @@ public class LoadingDeliveryService {
|
|||||||
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
|
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
|
||||||
}
|
}
|
||||||
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key,
|
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,
|
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
|
||||||
long operatorId, String operatorName, String key,
|
long operatorId, String operatorName, String key,
|
||||||
CompleteLoadingRequest request,
|
CompleteLoadingRequest request,
|
||||||
CompletionTarget target) {
|
CompletionTarget target,
|
||||||
|
boolean acceptedPdaCompletion) {
|
||||||
if ("LOADED".equals(target.loadedChildStatus())) {
|
if ("LOADED".equals(target.loadedChildStatus())) {
|
||||||
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
|
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
|
||||||
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
|
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
|
||||||
@@ -180,9 +206,9 @@ public class LoadingDeliveryService {
|
|||||||
if (!"LOADING".equals(target.loadedChildStatus())) {
|
if (!"LOADING".equals(target.loadedChildStatus())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
|
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
|
||||||
}
|
}
|
||||||
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
|
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
|
||||||
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
|
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
|
||||||
requireCompletedPicking(tenantId, target.sourceInboundId());
|
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
|
||||||
requireCompletionEvidence(tenantId, evidenceInboundId);
|
requireCompletionEvidence(tenantId, evidenceInboundId);
|
||||||
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
|
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
|
||||||
request.expectedVersion()) != 1) throw concurrent();
|
request.expectedVersion()) != 1) throw concurrent();
|
||||||
@@ -295,9 +321,21 @@ public class LoadingDeliveryService {
|
|||||||
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
|
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireCompletedPicking(long tenantId, long inboundId) {
|
/** worker 先完成各入库单,之后才完成柜次计划;这里不能循环要求计划已经 COMPLETED。 */
|
||||||
if (!canonicalPicking(tenantId, inboundId).completed()) {
|
private void requireLoadingPlanGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
|
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,
|
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
|
||||||
int completedTaskCount, int unresolvedCount) {
|
int completedTaskCount, int unresolvedCount) {
|
||||||
boolean completed() {
|
boolean resolved() {
|
||||||
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
|
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,
|
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
|
||||||
String operation, String key, String hash) {
|
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);
|
validateKey(key);
|
||||||
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
|
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
|
||||||
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
|
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
|
||||||
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
|
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, "相同请求正在处理中,请稍后重试");
|
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
|
||||||
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
|
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
|
||||||
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
|
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param;
|
|||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
|
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
|
||||||
@@ -15,6 +16,9 @@ public interface LoadingCompletedEventMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
|
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.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,
|
i.account_id, i.inbound_id AS source_inbound_id,
|
||||||
COALESCE(o.root_inbound_id, o.id) AS root_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,
|
o.order_id AS source_order_id, o.order_no AS source_order_no,
|
||||||
@@ -49,7 +53,8 @@ public interface LoadingCompletedEventMapper {
|
|||||||
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
|
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT i.account_id, i.inbound_id AS source_inbound_id,
|
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.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
|
||||||
i.product_name_zh, i.package_count, i.product_quantity,
|
i.product_name_zh, i.package_count, i.product_quantity,
|
||||||
label.box_code
|
label.box_code
|
||||||
@@ -79,7 +84,10 @@ public interface LoadingCompletedEventMapper {
|
|||||||
class Row {
|
class Row {
|
||||||
private Long loadingPlanId; private String planNo; private Long containerBookingId;
|
private Long loadingPlanId; private String planNo; private Long containerBookingId;
|
||||||
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
|
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 sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
|
||||||
private Long loadingPlanItemId;
|
private Long loadingPlanItemId;
|
||||||
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
|
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
|
||||||
@@ -95,7 +103,8 @@ public interface LoadingCompletedEventMapper {
|
|||||||
|
|
||||||
@Getter @Setter
|
@Getter @Setter
|
||||||
class PalletRow {
|
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 Integer allocationNo; private Integer allocatedPackageCount;
|
||||||
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
|
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
|
||||||
private Integer packageCount; private Integer productQuantity; private String boxCode;
|
private Integer packageCount; private Integer productQuantity; private String boxCode;
|
||||||
|
|||||||
+109
@@ -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 任务中的明细。
|
* 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。
|
||||||
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
|
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
|
||||||
|
* 异步完成期间任务会在 COMPLETING/COMPLETION_FAILED 间切换,不能把任务状态当成明细未完成事实。
|
||||||
*/
|
*/
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT COUNT(*)
|
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
|
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
|
WHERE pick_item.loading_plan_item_id = plan_item.id
|
||||||
AND pick_task.loading_plan_id = #{planId}
|
AND pick_task.loading_plan_id = #{planId}
|
||||||
AND pick_task.task_status = 'LOADING'
|
|
||||||
AND (
|
AND (
|
||||||
(pick_item.item_status = 'PICKED'
|
(pick_item.item_status = 'PICKED'
|
||||||
AND pick_item.picked_package_count = plan_item.package_count)
|
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}")
|
+ "AND task_status='LOADING' AND version=#{expectedVersion}")
|
||||||
int touch(@Param("taskId") long taskId, @Param("expectedVersion") int 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("""
|
||||||
SELECT DISTINCT task.*
|
SELECT DISTINCT task.*
|
||||||
FROM fl_loading_pick_task 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.merged_into_container_booking_id AS mergedIntoContainerBookingId,
|
||||||
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
|
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
|
||||||
CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'
|
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 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
|
WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0
|
||||||
THEN 'AWAITING_REVIEW'
|
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_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
|
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
|
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})
|
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
|
||||||
<if test="keyword != null and keyword != ''">
|
<if test="keyword != null and keyword != ''">
|
||||||
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
|
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
|
||||||
|
|||||||
+34
@@ -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) {
|
private List<History> histories(long planId) {
|
||||||
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
|
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.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
|
||||||
row.getOccurredAt())).toList();
|
row.getOccurredAt())).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ public class LoadingPickRepository {
|
|||||||
return tasks.touch(taskId, expectedVersion);
|
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) {
|
public LoadingPickTaskEntity taskByPlan(long planId) {
|
||||||
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||||
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
|
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
|
||||||
|
|||||||
+34
-5
@@ -37,7 +37,7 @@ import java.util.Map;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
|
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
|
||||||
@Service
|
@Service
|
||||||
public class LoadingCompletedOutboxService {
|
public class LoadingCompletedOutboxService {
|
||||||
private static final long HANDOVER_VERSION = 2L;
|
private static final long HANDOVER_VERSION = 2L;
|
||||||
@@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService {
|
|||||||
public void publish(long planId) {
|
public void publish(long planId) {
|
||||||
List<Row> rows = snapshotMapper.rows(planId);
|
List<Row> rows = snapshotMapper.rows(planId);
|
||||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||||
|
validateCustomerSnapshots(rows);
|
||||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||||
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
|
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
|
||||||
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
|
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();
|
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
|
||||||
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
|
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
|
||||||
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
|
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);
|
JsonNode payloadNode = objectMapper.valueToTree(payload);
|
||||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
|
||||||
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
|
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.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
|
||||||
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
|
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
|
||||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
||||||
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
|
||||||
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
||||||
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
|
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
|
||||||
|
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
|
||||||
destinationType(sorted));
|
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) {
|
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
|
||||||
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
|
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); }
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
|
||||||
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
|
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 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 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 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); }
|
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.ReviewRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
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.BatchRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
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.LoadingPickHistoryEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
||||||
|
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
|
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
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.CartonLabelRow;
|
||||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
|
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -66,6 +69,7 @@ import java.util.Objects;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
|
||||||
/** 装柜中 PDA 拣货任务及明细同步。 */
|
/** 装柜中 PDA 拣货任务及明细同步。 */
|
||||||
@Service
|
@Service
|
||||||
@@ -76,6 +80,8 @@ public class LoadingPickService {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoadingPickRepository repository;
|
private LoadingPickRepository repository;
|
||||||
@Resource
|
@Resource
|
||||||
|
private LoadingCompletionCommandRepository completionCommands;
|
||||||
|
@Resource
|
||||||
private SmartLoadingRepository smartLoadingRepository;
|
private SmartLoadingRepository smartLoadingRepository;
|
||||||
@Resource
|
@Resource
|
||||||
private OceanBookingRepository oceanBookingRepository;
|
private OceanBookingRepository oceanBookingRepository;
|
||||||
@@ -440,9 +446,31 @@ public class LoadingPickService {
|
|||||||
String operationId = operation(request.operationId());
|
String operationId = operation(request.operationId());
|
||||||
String remark = trim(request.remark());
|
String remark = trim(request.remark());
|
||||||
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
|
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
|
||||||
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
|
LoadingCompletionCommandEntity replay = completionCommands.operation(operationId);
|
||||||
if (replay != null) return replay;
|
if (replay != null) {
|
||||||
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
|
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())) {
|
if (!request.expectedTaskVersion().equals(task.getVersion())) {
|
||||||
throw conflict("PDA任务版本已变化,请刷新后复核");
|
throw conflict("PDA任务版本已变化,请刷新后复核");
|
||||||
}
|
}
|
||||||
@@ -454,40 +482,122 @@ public class LoadingPickService {
|
|||||||
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
||||||
.toList();
|
.toList();
|
||||||
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
|
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
|
||||||
LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0);
|
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
|
||||||
List<LoadingPickItemMapper.InboundOwner> owners = discoveredOwners.stream()
|
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)
|
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
|
||||||
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
|
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
|
||||||
.toList();
|
.toList();
|
||||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
||||||
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
|
long evidenceInboundId = owners.get(0).inboundId();
|
||||||
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
|
for (int index = 0; index < owners.size(); index++) {
|
||||||
completionStage(taskId, "完成前业务门禁", () ->
|
requireCompletionLease(command.getId(), worker);
|
||||||
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
|
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
||||||
evidenceOwner.inboundId()));
|
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())) {
|
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||||
throw conflict("柜次仍有未完成PDA拣货的箱数");
|
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
|
||||||
}
|
}
|
||||||
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
|
if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(),
|
||||||
throw conflict("PDA任务已经复核");
|
command.getOperatorName(), command.getRemark()) != 1) {
|
||||||
|
throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核"));
|
||||||
}
|
}
|
||||||
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
|
if (repository.operation(command.getOperationId()) == null) {
|
||||||
completionStage(taskId, "装柜计划完成", () ->
|
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
||||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
|
command.getOperatorId(), command.getOperatorName());
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
completionStage(taskId, "完成事件发布", () ->
|
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
||||||
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
command.getOperatorId(), command.getOperatorName()));
|
||||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
|
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
|
||||||
return view(requiredTask(taskId), null);
|
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) {
|
private void completionStage(long taskId, String stage, Runnable action) {
|
||||||
@@ -692,6 +802,13 @@ public class LoadingPickService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
|
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
|
List<LoadingPickItemEntity> source = warehouseId == null
|
||||||
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
|
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
|
||||||
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
|
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(),
|
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
|
||||||
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
|
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
|
||||||
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
|
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
|
||||||
|
: "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING"
|
||||||
|
: "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED"
|
||||||
: assigned == null ? "UNASSIGNED"
|
: assigned == null ? "UNASSIGNED"
|
||||||
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
|
: 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());
|
var owners = repository.inboundOwners(task.getId());
|
||||||
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
|
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
|
||||||
var owner = detailInboundId == null ? null : owners.stream()
|
var owner = detailInboundId == null ? null : owners.stream()
|
||||||
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
|
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
|
||||||
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
|
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
|
||||||
? null : repository.inbound(owner.tenantId(), owner.inboundId());
|
? 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(),
|
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
|
||||||
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
|
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
|
||||||
inbound == null ? null : inbound.version(), task.getCabinetNo(),
|
inbound == null ? null : inbound.version(), task.getCabinetNo(),
|
||||||
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
|
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
|
||||||
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
|
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) {
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
|
|||||||
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
|
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
|
||||||
@Service
|
@Service
|
||||||
public class PdaPickSettlementService {
|
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 long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
|
||||||
private static final String SOURCE_SERVICE = "firstleg-service";
|
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,
|
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
|
||||||
List<LoadingPickItemEntity> taskItems, long taskId,
|
List<LoadingPickItemEntity> taskItems, long taskId,
|
||||||
long operatorId, String operatorName) {
|
long operatorId, String operatorName) {
|
||||||
@@ -379,7 +411,7 @@ public class PdaPickSettlementService {
|
|||||||
|
|
||||||
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
|
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
|
||||||
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
|
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
|
||||||
.sorted().toList();
|
.distinct().sorted().toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
|
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
|
||||||
@@ -422,18 +454,51 @@ public class PdaPickSettlementService {
|
|||||||
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
|
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
|
||||||
releasedReservationIds, remainingItems));
|
releasedReservationIds, remainingItems));
|
||||||
} catch (FeignException exception) {
|
} 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库位资源");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
|
||||||
}
|
}
|
||||||
if (response == null || !response.successful() || response.data() == null
|
if (response == null || !response.successful() || response.data() == null
|
||||||
|| response.data().released() == null || response.data().transferred() == null
|
|| response.data().released() == null || response.data().transferred() == null
|
||||||
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|
||||||
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
|
|| !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());
|
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
|
||||||
throw new BizException(code, response == null || response.message() == null
|
throw new SettlementFailure(code, response == null || response.message() == null
|
||||||
? "仓储服务原子结算PDA库位资源失败" : response.message());
|
? "仓储服务原子结算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) {
|
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
|
||||||
try {
|
try {
|
||||||
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
|
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',
|
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
|
||||||
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
|
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
|
||||||
container_no VARCHAR(64) 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 BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
|
||||||
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
|
||||||
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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),
|
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
|
||||||
KEY idx_loading_pick_container (container_no, task_status, started_at),
|
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 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装柜拣货任务';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
|
||||||
pick_task_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
|
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
|
WHERE pick_item.loading_plan_item_id = item.id
|
||||||
AND pick_task.loading_plan_id = OLD.id
|
AND pick_task.loading_plan_id = OLD.id
|
||||||
AND pick_task.task_status = 'LOADING'
|
|
||||||
AND (
|
AND (
|
||||||
(pick_item.item_status = 'PICKED'
|
(pick_item.item_status = 'PICKED'
|
||||||
AND pick_item.picked_package_count = item.package_count)
|
AND pick_item.picked_package_count = item.package_count)
|
||||||
@@ -1018,8 +1060,10 @@ BEGIN
|
|||||||
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
|
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
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 IF;
|
||||||
END$$
|
END$$
|
||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
|
|||||||
@@ -427,10 +427,11 @@ components:
|
|||||||
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
|
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
|
||||||
OperationHistory:
|
OperationHistory:
|
||||||
type: object
|
type: object
|
||||||
required: [historyId, actionType, operatorId, occurredAt]
|
required: [historyId, operationId, actionType, operatorId, occurredAt]
|
||||||
properties:
|
properties:
|
||||||
historyId: {$ref: '#/components/schemas/LongId'}
|
historyId: {$ref: '#/components/schemas/LongId'}
|
||||||
itemId: {$ref: '#/components/schemas/NullableLongId'}
|
itemId: {$ref: '#/components/schemas/NullableLongId'}
|
||||||
|
operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重}
|
||||||
actionType: {type: string, description: 操作类型}
|
actionType: {type: string, description: 操作类型}
|
||||||
fromStatus: {type: string, nullable: true, description: 操作前状态}
|
fromStatus: {type: string, nullable: true, description: 操作前状态}
|
||||||
toStatus: {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:
|
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
|
||||||
post:
|
post:
|
||||||
summary: PDA拣货全部处理后人工确认装柜完成
|
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'}]
|
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
|
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
|
||||||
responses:
|
responses:
|
||||||
'200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409}
|
'200': {description: 已可靠受理;taskStatus=COMPLETING且workflowStatus=PROCESSING,或幂等返回最终COMPLETED结果;冲突返回A0409}
|
||||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
|
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
|
||||||
get:
|
get:
|
||||||
summary: Read immutable task-level claim, assignment and review audit records
|
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}
|
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}
|
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}
|
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]}
|
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]}
|
||||||
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, 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}
|
version: {type: integer, minimum: 0}
|
||||||
assignment: {type: object, nullable: true}
|
assignment: {type: object, nullable: true}
|
||||||
review: {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}
|
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task}
|
||||||
warehouseId: {type: integer, format: int64}
|
warehouseId: {type: integer, format: int64}
|
||||||
warehouseName: {type: string, nullable: true}
|
warehouseName: {type: string, nullable: true}
|
||||||
taskStatus: {type: string, enum: [LOADING]}
|
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED]}
|
||||||
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}
|
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}
|
version: {type: integer, minimum: 0}
|
||||||
assignmentVersion: {type: integer, minimum: 0}
|
assignmentVersion: {type: integer, minimum: 0}
|
||||||
itemCount: {type: integer, minimum: 1}
|
itemCount: {type: integer, minimum: 1}
|
||||||
@@ -236,6 +237,22 @@ components:
|
|||||||
expectedTaskVersion: {type: integer, minimum: 0}
|
expectedTaskVersion: {type: integer, minimum: 0}
|
||||||
operationId: {type: string, maxLength: 128}
|
operationId: {type: string, maxLength: 128}
|
||||||
remark: {type: string, maxLength: 500}
|
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:
|
CompleteLoadingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [expectedTaskVersion, operationId]
|
required: [expectedTaskVersion, operationId]
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ class LoadingListControllerContractTest {
|
|||||||
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
|
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
|
||||||
"destinationWarehouse", "productQuantity", "frozenAt");
|
"destinationWarehouse", "productQuantity", "frozenAt");
|
||||||
assertThat(properties(schemas, "OperationHistory")).containsKeys(
|
assertThat(properties(schemas, "OperationHistory")).containsKeys(
|
||||||
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
|
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
|
||||||
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
|
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
|
||||||
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
|
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
|
||||||
"containerNo", "voyageNo", "expectedVersion", "operationReason");
|
"containerNo", "voyageNo", "expectedVersion", "operationReason");
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest {
|
|||||||
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
|
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
|
||||||
.contains("Task detail is served only from the ACTIVE container booking")
|
.contains("Task detail is served only from the ACTIVE container booking")
|
||||||
.contains("Enforced by the server-side pending-task query")
|
.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");
|
.contains("CompleteLoadingRequest");
|
||||||
|
|
||||||
String taskLookup = yaml.substring(
|
String taskLookup = yaml.substring(
|
||||||
|
|||||||
+93
-1
@@ -25,6 +25,7 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
@@ -195,6 +196,65 @@ class LoadingDeliveryServiceTest {
|
|||||||
verify(repository).findInboundForUpdate(10, 20);
|
verify(repository).findInboundForUpdate(10, 20);
|
||||||
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
verify(repository).completeLoadedChild(10, 20, 7, 7);
|
||||||
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
|
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
|
@Test
|
||||||
@@ -213,6 +273,8 @@ class LoadingDeliveryServiceTest {
|
|||||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||||
verify(repository).findInboundForUpdate(10, 21);
|
verify(repository).findInboundForUpdate(10, 21);
|
||||||
verify(repository).completeLoadedChild(10, 20, 7, 2);
|
verify(repository).completeLoadedChild(10, 20, 7, 2);
|
||||||
|
verify(loadingModelBridge).requireStarted(10, 20);
|
||||||
|
verify(loadingModelBridge, never()).requireCompleted(10, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -350,6 +412,24 @@ class LoadingDeliveryServiceTest {
|
|||||||
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
|
"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
|
@Test
|
||||||
void voidsPhotoWithReasonAndKeepsTheRecord() {
|
void voidsPhotoWithReasonAndKeepsTheRecord() {
|
||||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||||
@@ -418,11 +498,16 @@ class LoadingDeliveryServiceTest {
|
|||||||
private void readyForPdaFinalize() {
|
private void readyForPdaFinalize() {
|
||||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
|
||||||
.thenReturn(true);
|
.thenReturn(true);
|
||||||
|
readyForPdaFinalizeFacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readyForPdaFinalizeFacts() {
|
||||||
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
|
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
|
||||||
when(progress.getExpectedCount()).thenReturn(5);
|
when(progress.getExpectedCount()).thenReturn(5);
|
||||||
when(progress.getPickedCount()).thenReturn(5);
|
when(progress.getPickedCount()).thenReturn(5);
|
||||||
when(progress.getTaskCount()).thenReturn(1);
|
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(progress.getUnresolvedCount()).thenReturn(0);
|
||||||
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
|
||||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||||
@@ -444,4 +529,11 @@ class LoadingDeliveryServiceTest {
|
|||||||
value.setResultJson(resultJson);
|
value.setResultJson(resultJson);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static LoadingOperationEntity processingOperation(String requestHash) {
|
||||||
|
LoadingOperationEntity value = new LoadingOperationEntity();
|
||||||
|
value.setRequestHash(requestHash);
|
||||||
|
value.setStatus("PROCESSING");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest {
|
|||||||
.contains("plan_item.loading_plan_id = #{planId}")
|
.contains("plan_item.loading_plan_id = #{planId}")
|
||||||
.contains("plan_item.deleted = 0")
|
.contains("plan_item.deleted = 0")
|
||||||
.contains("pick_task.loading_plan_id = #{planId}")
|
.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.item_status = 'PICKED'")
|
||||||
.contains("pick_item.picked_package_count = plan_item.package_count")
|
.contains("pick_item.picked_package_count = plan_item.package_count")
|
||||||
.contains("pick_item.item_status = 'PARTIAL'")
|
.contains("pick_item.item_status = 'PARTIAL'")
|
||||||
|
|||||||
+3
-1
@@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest {
|
|||||||
.getAnnotation(Select.class).value()[0];
|
.getAnnotation(Select.class).value()[0];
|
||||||
|
|
||||||
assertThat(sql)
|
assertThat(sql)
|
||||||
.contains("task.task_status = 'LOADING'")
|
.contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')")
|
||||||
.contains("booking.record_status = 'ACTIVE'")
|
.contains("booking.record_status = 'ACTIVE'")
|
||||||
.contains("booking.source_type AS sourceType")
|
.contains("booking.source_type AS sourceType")
|
||||||
.contains("booking.record_status AS recordStatus")
|
.contains("booking.record_status AS recordStatus")
|
||||||
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
|
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
|
||||||
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
|
.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("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
|
||||||
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
|
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
|
||||||
.contains("THEN 'AWAITING_REVIEW'")
|
.contains("THEN 'AWAITING_REVIEW'")
|
||||||
|
|||||||
+8
-3
@@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void assertLoadingCompletionTrigger(String sql) {
|
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("AND NOT EXISTS (")
|
||||||
.contains("pick_task.loading_plan_id = OLD.id")
|
.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 = 'PICKED'")
|
||||||
.contains("pick_item.item_status = 'PARTIAL'")
|
.contains("pick_item.item_status = 'PARTIAL'")
|
||||||
.contains("pick_item.picked_package_count > 0")
|
.contains("pick_item.picked_package_count > 0")
|
||||||
@@ -308,7 +313,7 @@ class SmartLoadingSqlContractTest {
|
|||||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
|
||||||
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
.contains("cabinet_no VARCHAR(64) NOT NULL")
|
||||||
.contains("container_no VARCHAR(64) 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_plan (loading_plan_id)")
|
||||||
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
|
||||||
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
|
||||||
|
|||||||
+78
-3
@@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
|
|
||||||
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
|
@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.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);
|
service.publish(90L);
|
||||||
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||||
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
|
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/skuCode").asText()).isEqualTo("SKU101");
|
||||||
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
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/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(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||||
assertThat(first.eventVersion()).isEqualTo(2);
|
assertThat(first.eventVersion()).isEqualTo(2);
|
||||||
}
|
}
|
||||||
@@ -72,6 +85,22 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
.hasMessageContaining("缺少 SKU");
|
.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 {
|
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
|
||||||
Row value = row(11, 100, 101);
|
Row value = row(11, 100, 101);
|
||||||
value.setDestinationType("FBA");
|
value.setDestinationType("FBA");
|
||||||
@@ -118,6 +147,41 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
.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 {
|
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
|
||||||
Row first = row(11, 100, 101); first.setPackageCount(1);
|
Row first = row(11, 100, 101); first.setPackageCount(1);
|
||||||
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
|
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
|
||||||
@@ -144,7 +208,11 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||||
|
|
||||||
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
|
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)) {
|
for (String sql : List.of(rowsSql, palletsSql)) {
|
||||||
assertThat(sql)
|
assertThat(sql)
|
||||||
.contains("actual_pick.loading_plan_item_id = i.id")
|
.contains("actual_pick.loading_plan_item_id = i.id")
|
||||||
@@ -158,6 +226,7 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
.doesNotContain("'PICKING'");
|
.doesNotContain("'PICKING'");
|
||||||
}
|
}
|
||||||
assertThat(palletsSql)
|
assertThat(palletsSql)
|
||||||
|
.contains("a.pallet_id", "a.reservation_id")
|
||||||
.contains("label.tenant_id = i.account_id")
|
.contains("label.tenant_id = i.account_id")
|
||||||
.contains("label.inbound_detail_id = i.inbound_detail_id")
|
.contains("label.inbound_detail_id = i.inbound_detail_id")
|
||||||
.contains("label.batch_status = 'ACTIVE'")
|
.contains("label.batch_status = 'ACTIVE'")
|
||||||
@@ -219,20 +288,26 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
private Row row(long account,long inbound,long detail) {
|
private Row row(long account,long inbound,long detail) {
|
||||||
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
|
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.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.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
|
||||||
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
||||||
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
||||||
r.setDestinationType("OVERSEAS");
|
r.setDestinationType("OVERSEAS");
|
||||||
r.setFulfillmentType("STANDARD");
|
r.setFulfillmentType("STANDARD");
|
||||||
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
|
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,
|
private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated,
|
||||||
long detail,String sku,int packages,int quantity,String boxCode) {
|
long detail,String sku,int packages,int quantity,String boxCode) {
|
||||||
PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound);
|
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.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
|
||||||
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
|
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
|
||||||
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
|
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.BatchItemRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
|
||||||
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
||||||
|
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
||||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
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.InboundStatusHistoryMapper;
|
||||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||||
|
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
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.Test;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.inOrder;
|
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.when;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.clearInvocations;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@SpringJUnitConfig(LoadingPickService.class)
|
@SpringJUnitConfig(LoadingPickService.class)
|
||||||
@@ -60,6 +65,8 @@ class LoadingPickServiceTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private LoadingPickRepository repository;
|
private LoadingPickRepository repository;
|
||||||
@MockBean
|
@MockBean
|
||||||
|
private LoadingCompletionCommandRepository completionCommands;
|
||||||
|
@MockBean
|
||||||
private SmartLoadingRepository smartLoadingRepository;
|
private SmartLoadingRepository smartLoadingRepository;
|
||||||
@MockBean
|
@MockBean
|
||||||
private OceanBookingRepository oceanBookingRepository;
|
private OceanBookingRepository oceanBookingRepository;
|
||||||
@@ -85,9 +92,13 @@ class LoadingPickServiceTest {
|
|||||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||||
booking.setId(99L);
|
booking.setId(99L);
|
||||||
booking.setContainerNo("CSLU1234567");
|
booking.setContainerNo("CSLU1234567");
|
||||||
|
booking.setWarehouseId(101L);
|
||||||
booking.setSourceType("SIMULATION");
|
booking.setSourceType("SIMULATION");
|
||||||
booking.setRecordStatus("ACTIVE");
|
booking.setRecordStatus("ACTIVE");
|
||||||
when(repository.activeBookingByTask(20L)).thenReturn(booking);
|
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
|
@Test
|
||||||
@@ -232,6 +243,7 @@ class LoadingPickServiceTest {
|
|||||||
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
|
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
|
||||||
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||||
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
|
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
|
||||||
|
booking.setWarehouseId(101L);
|
||||||
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
|
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
|
||||||
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
|
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
|
||||||
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
|
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
|
||||||
@@ -414,6 +426,37 @@ class LoadingPickServiceTest {
|
|||||||
assertThat(result.sourceType()).isEqualTo("SIMULATION");
|
assertThat(result.sourceType()).isEqualTo("SIMULATION");
|
||||||
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
|
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
|
||||||
assertThat(result.mergedIntoContainerBookingId()).isNull();
|
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
|
@Test
|
||||||
@@ -488,6 +531,7 @@ class LoadingPickServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||||
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
|
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
LoadingPickItemEntity item = item();
|
LoadingPickItemEntity item = item();
|
||||||
@@ -538,6 +582,7 @@ class LoadingPickServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||||
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
|
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
LoadingPickItemEntity item = item();
|
LoadingPickItemEntity item = item();
|
||||||
@@ -568,6 +613,7 @@ class LoadingPickServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||||
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
|
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||||
@@ -592,6 +638,7 @@ class LoadingPickServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||||
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
|
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||||
@@ -613,6 +660,199 @@ class LoadingPickServiceTest {
|
|||||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
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
|
@Test
|
||||||
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
|
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
|
||||||
LoadingPickTaskEntity task = task();
|
LoadingPickTaskEntity task = task();
|
||||||
@@ -814,6 +1054,21 @@ class LoadingPickServiceTest {
|
|||||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
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() {
|
private static LoadingPickTaskEntity task() {
|
||||||
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
|
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
|
||||||
task.setId(20L);
|
task.setId(20L);
|
||||||
|
|||||||
+6
-3
@@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest {
|
|||||||
when(splits.lockParent(31L)).thenReturn(parent(20));
|
when(splits.lockParent(31L)).thenReturn(parent(20));
|
||||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
|
||||||
when(allocations.lockConfirmed(10001L, 31L))
|
InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10);
|
||||||
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 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(
|
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())));
|
List.of())));
|
||||||
writes(0, 20);
|
writes(0, 20);
|
||||||
|
|
||||||
|
|||||||
+32
-8
@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
|
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
|
||||||
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
|
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
|
||||||
@@ -50,12 +51,29 @@ class TaillegRealProducerAcceptanceTest {
|
|||||||
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
|
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
|
||||||
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
|
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
|
||||||
eventKey));
|
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() {
|
private void seedCompletedLoadingPlan() {
|
||||||
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
|
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)",
|
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
|
||||||
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
|
"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," +
|
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);
|
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
|
||||||
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
|
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);
|
MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3);
|
||||||
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
|
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
|
||||||
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
|
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
|
||||||
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
|
seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F");
|
||||||
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
|
seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D");
|
||||||
"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");
|
|
||||||
jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," +
|
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,'真实验收')",
|
"container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')",
|
||||||
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
|
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);
|
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) {
|
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," +
|
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')",
|
"inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",
|
||||||
|
|||||||
@@ -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`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
|
| 内部模块化基线与依赖分析 | `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` |
|
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
|
||||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
| 内部令牌保护 | `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 合同锁定 |
|
| 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` |
|
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.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 已移除 |
|
| 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 已移除 |
|
||||||
|
|||||||
+13
@@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
|||||||
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||||
|
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||||
@@ -71,6 +72,18 @@ public class LastlegOrderController {
|
|||||||
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
|
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}")
|
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
@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)
|
@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(
|
public record TransferDestination(
|
||||||
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
@NotEmpty @Size(max = 200) List<Long> orderIds,
|
||||||
@NotBlank @Size(max = 128) String warehouseId,
|
@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
|
||||||
|
) {
|
||||||
|
}
|
||||||
+22
-1
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
|||||||
import com.baiyue.common.core.api.PageResult;
|
import com.baiyue.common.core.api.PageResult;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
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.BatchFbaAppointmentRequest;
|
||||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||||
@@ -49,7 +50,19 @@ public class InboundPlanController {
|
|||||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
@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}")
|
@GetMapping("/{planNo}")
|
||||||
@@ -118,4 +131,12 @@ public class InboundPlanController {
|
|||||||
}
|
}
|
||||||
return String.valueOf(user.tenantId());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
package com.baiyue.lastleg.inboundplan.dto;
|
package com.baiyue.lastleg.inboundplan.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
public record ConvertInboundPlanRequest(
|
public record ConvertInboundPlanRequest(
|
||||||
@NotNull Long orderId,
|
@NotNull Long orderId,
|
||||||
String customerId
|
@Size(max = 128) String customerId
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -12,6 +12,12 @@ import java.util.List;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface InboundPlanOrderMapper {
|
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")
|
@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);
|
String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -150,6 +150,9 @@ public class ReceivingContainerService {
|
|||||||
requireSessionWarehouse(active, overseasWarehouseId);
|
requireSessionWarehouse(active, overseasWarehouseId);
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
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);
|
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||||
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
|
save(active, "PREPARE", request.idempotencyKey(), command, result); return result;
|
||||||
}
|
}
|
||||||
@@ -253,7 +256,13 @@ public class ReceivingContainerService {
|
|||||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||||
String customerId = session.getCustomerId();
|
String customerId = session.getCustomerId();
|
||||||
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
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);
|
validatePallet(ref, pallet);
|
||||||
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
||||||
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
|
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) {
|
private boolean warehouseEligible(List<InboundPlan> containerPlans, long overseasWarehouseId) {
|
||||||
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
|
Set<Long> assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId)
|
||||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream()
|
if (assigned.size() > 1) {
|
||||||
.anyMatch(plan -> plan.getOverseasWarehouseId() == null))) {
|
|
||||||
throw conflict("Container plans have inconsistent receiving warehouse assignments");
|
throw conflict("Container plans have inconsistent receiving warehouse assignments");
|
||||||
}
|
}
|
||||||
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
|
return assigned.isEmpty() || assigned.contains(overseasWarehouseId);
|
||||||
|
|||||||
+63
@@ -31,6 +31,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -59,6 +60,9 @@ public class InboundPlanService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
|
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);
|
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
|
||||||
if (orders == null || orders.isEmpty()) {
|
if (orders == null || orders.isEmpty()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
|
||||||
@@ -70,7 +74,16 @@ public class InboundPlanService {
|
|||||||
return detail(customerId, existing);
|
return detail(customerId, existing);
|
||||||
}
|
}
|
||||||
validateOrders(orders);
|
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();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
InboundPlan plan = new InboundPlan();
|
InboundPlan plan = new InboundPlan();
|
||||||
plan.setId(IdWorker.getId());
|
plan.setId(IdWorker.getId());
|
||||||
@@ -118,6 +131,56 @@ public class InboundPlanService {
|
|||||||
return detail(customerId, plan);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
|
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.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
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.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
import org.apache.ibatis.annotations.Update;
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
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<>'' " +
|
||||||
|
"<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 >=#{q.createdFrom} </if>" +
|
||||||
|
"<if test='q.createdTo != null'>AND o.created_at <=#{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)>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,''))<>COUNT(*) " +
|
||||||
|
"OR COUNT(DISTINCT NULLIF(o.container_no,''))<>1 THEN '柜号缺失或不一致' " +
|
||||||
|
"WHEN SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 0 ELSE 1 END)>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} " +
|
@Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " +
|
||||||
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
|
"AND source_inbound_id=#{sourceInboundId} LIMIT 1")
|
||||||
LastlegOrder findSource(@Param("customerId") String customerId,
|
LastlegOrder findSource(@Param("customerId") String customerId,
|
||||||
|
|||||||
+19
@@ -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();
|
||||||
|
}
|
||||||
+16
-22
@@ -1,35 +1,29 @@
|
|||||||
package com.baiyue.lastleg.service;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.time.LocalDate;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.time.ZoneId;
|
||||||
import java.security.MessageDigest;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class LastlegOrderNoGenerator {
|
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() {
|
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. */
|
String next(LocalDate businessDate) {
|
||||||
public String forContainerNo(String containerNo) {
|
sequenceMapper.nextValue(businessDate);
|
||||||
if (containerNo == null || containerNo.isBlank()) {
|
long sequence = sequenceMapper.currentValue();
|
||||||
throw new IllegalArgumentException("containerNo is required");
|
if (sequence < 1 || sequence > 9999) {
|
||||||
}
|
throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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.LastlegOrderDetailResponse;
|
||||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||||
|
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||||
@@ -46,6 +47,21 @@ public class LastlegOrderService {
|
|||||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
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) {
|
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
|
||||||
LastlegOrder order = findOwned(customerId, orderId);
|
LastlegOrder order = findOwned(customerId, orderId);
|
||||||
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
|
List<LastlegOrderDetail> cargo = detailMapper.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
|
||||||
|
|||||||
+2
-1
@@ -83,7 +83,7 @@ public class LoadingCompletedConsumer {
|
|||||||
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
|
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
|
||||||
payload.containerNo(), 0, envelope.handoverVersion());
|
payload.containerNo(), 0, envelope.handoverVersion());
|
||||||
|
|
||||||
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
String orderNo = orderNoGenerator.next();
|
||||||
JsonNode rawOrders = envelope.payload().path("orders");
|
JsonNode rawOrders = envelope.payload().path("orders");
|
||||||
for (int index = 0; index < payload.orders().size(); index++) {
|
for (int index = 0; index < payload.orders().size(); index++) {
|
||||||
var source = payload.orders().get(index);
|
var source = payload.orders().get(index);
|
||||||
@@ -183,6 +183,7 @@ public class LoadingCompletedConsumer {
|
|||||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
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.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
|
||||||
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
|
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
|
||||||
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
|
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)
|
KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence)
|
||||||
) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS ll_order (
|
||||||
id BIGINT NOT NULL COMMENT '雪花 ID',
|
id BIGINT NOT NULL COMMENT '雪花 ID',
|
||||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
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),
|
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_group(customer_id,order_no),
|
||||||
KEY idx_ll_order_container(customer_id,container_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='尾程订单主表';
|
) ENGINE=InnoDB COMMENT='尾程订单主表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_order_detail (
|
CREATE TABLE IF NOT EXISTS ll_order_detail (
|
||||||
@@ -192,6 +199,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
|
|||||||
ALTER TABLE ll_order 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'));
|
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;
|
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'));
|
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;
|
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'));
|
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: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||||
- { name: createdTo, 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 } }
|
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:
|
/api/platform/v1/last-leg/dispatches:
|
||||||
get:
|
get:
|
||||||
summary: 分页查询排车单
|
summary: 分页查询排车单
|
||||||
@@ -531,6 +592,20 @@ paths:
|
|||||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||||
components:
|
components:
|
||||||
schemas:
|
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:
|
PdaWarehouseScan:
|
||||||
type: object
|
type: object
|
||||||
required: [code]
|
required: [code]
|
||||||
|
|||||||
+18
@@ -50,6 +50,24 @@ class LastlegOrderControllerTest {
|
|||||||
verify(service).page(eq("200"), eq(query));
|
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
|
@Test
|
||||||
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
|
void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() {
|
||||||
EventEnvelope event = mock(EventEnvelope.class);
|
EventEnvelope event = mock(EventEnvelope.class);
|
||||||
|
|||||||
+48
@@ -3,6 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller;
|
|||||||
import com.baiyue.common.core.api.PageResult;
|
import com.baiyue.common.core.api.PageResult;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
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.dto.UpdateFbaAppointmentRequest;
|
||||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -57,6 +60,51 @@ class InboundPlanControllerTest {
|
|||||||
verify(appointmentService).update("100", "APT-1", request);
|
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() {
|
private static AuthenticatedUser platform() {
|
||||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||||
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
Set.of("platform:overseas-warehouse:inbound-order:list"));
|
||||||
|
|||||||
+107
@@ -161,6 +161,26 @@ class InboundPlanServiceTest {
|
|||||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
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
|
@Test
|
||||||
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
|
void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() {
|
||||||
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
|
SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1",
|
||||||
@@ -171,6 +191,93 @@ class InboundPlanServiceTest {
|
|||||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
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) {
|
private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
|
||||||
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
|
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
|
||||||
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
|
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
|
||||||
|
|||||||
+49
-24
@@ -1,31 +1,56 @@
|
|||||||
package com.baiyue.lastleg.service;
|
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 org.junit.jupiter.api.Test;
|
||||||
import java.util.Set;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import java.util.concurrent.*;
|
import org.mockito.Mock;
|
||||||
import java.util.stream.IntStream;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
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 {
|
class LastlegOrderNoGeneratorTest {
|
||||||
@Test void actualContainerNumberIsStableAndShared() {
|
@Mock LastlegOrderNoDailySequenceMapper sequenceMapper;
|
||||||
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
|
private LastlegOrderNoGenerator generator;
|
||||||
assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633"));
|
|
||||||
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
|
@BeforeEach
|
||||||
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
|
void setUp() {
|
||||||
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
|
generator = new LastlegOrderNoGenerator();
|
||||||
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
|
ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception {
|
@Test
|
||||||
LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator();
|
void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() {
|
||||||
String first=beforeRestart.next();
|
LocalDate date = LocalDate.of(2026, 8, 27);
|
||||||
LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator();
|
when(sequenceMapper.currentValue()).thenReturn(1L, 2L);
|
||||||
assertNotEquals(first,afterRestart.next());
|
|
||||||
ExecutorService pool=Executors.newFixedThreadPool(8);
|
assertEquals("LMO-260827-0001", generator.next(date));
|
||||||
try {
|
assertEquals("LMO-260827-0002", generator.next(date));
|
||||||
var tasks=IntStream.range(0,1000).mapToObj(i->(Callable<String>)afterRestart::next).toList();
|
verify(sequenceMapper, times(2)).nextValue(date);
|
||||||
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());
|
@Test
|
||||||
assertEquals(1000,ids.size());
|
void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception {
|
||||||
} finally { pool.shutdownNow(); }
|
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;
|
package com.baiyue.lastleg.service;
|
||||||
|
|
||||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||||
|
import com.baiyue.lastleg.dto.PendingContainerSummary;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||||
@@ -19,6 +20,8 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.util.List;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
@@ -133,6 +136,33 @@ class LastlegOrderServiceTest {
|
|||||||
assertEquals(25, page.getValue().getSize());
|
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) {
|
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
|
||||||
LastlegOrder order = new LastlegOrder();
|
LastlegOrder order = new LastlegOrder();
|
||||||
order.setId(id);
|
order.setId(id);
|
||||||
|
|||||||
+6
-6
@@ -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.*;
|
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)
|
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
||||||
class LoadingCompletedConsumerTest {
|
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;
|
@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); 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)); }
|
@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->"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()); }
|
@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 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 repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
@Test void 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"));
|
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 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(){
|
@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 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 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(
|
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-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"))));
|
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);
|
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));
|
assertEquals(1,service.consume(event));
|
||||||
@@ -112,6 +112,6 @@ class LoadingCompletedConsumerTest {
|
|||||||
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||||
verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
|
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); }
|
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); }
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -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
|
@Test
|
||||||
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
|
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
|
||||||
ClassLoader loader = getClass().getClassLoader();
|
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))
|
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
|
||||||
.as("Print must exclude the JDBC starter inherited through common-core")
|
.as("Print must exclude the JDBC starter inherited through common-core")
|
||||||
.isFalse();
|
.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 {
|
import {
|
||||||
confirmedNetSummary,
|
confirmedNetSummary,
|
||||||
|
normalizeWeightPalletOptionPage,
|
||||||
normalizedGrossKg,
|
normalizedGrossKg,
|
||||||
palletOptionsPath,
|
palletOptionsPath,
|
||||||
selectWeightPallet,
|
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.match(optionsPath, /keyword=TP-09/)
|
||||||
assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50)
|
assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50)
|
||||||
assert.deepEqual(weightPalletOptionRecords({
|
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' }])
|
}), [{ 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([{ 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.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath'))
|
||||||
assert.match(source, /form\.append\('file'/)
|
assert.match(source, /form\.append\('file'/)
|
||||||
assert.doesNotMatch(source, /form\.append\('palletId'/)
|
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()}`
|
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 (!page || Array.isArray(page) || !Array.isArray(page.records)) return null
|
||||||
if (!Number.isInteger(Number(page.page)) || Number(page.page) < 1) return null
|
const normalized = Object.fromEntries(['page', 'size', 'total', 'pages']
|
||||||
if (!Number.isInteger(Number(page.size)) || Number(page.size) < 1
|
.map((key) => [key, Number(page[key])]))
|
||||||
|| Number(page.size) > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null
|
if (!Number.isInteger(normalized.page) || normalized.page < 1) return null
|
||||||
if (page.records.length > Number(page.size)) return null
|
if (!Number.isInteger(normalized.size) || normalized.size < 1
|
||||||
return page.records
|
|| 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 = {}) {
|
async function createUploadedResult(context, target, pallet, image, overrides = {}) {
|
||||||
@@ -401,7 +409,12 @@ export async function run(context) {
|
|||||||
)
|
)
|
||||||
const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse,
|
const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse,
|
||||||
`inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS)
|
`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
|
const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0
|
||||||
&& palletOptions.every(palletSnapshotComplete)
|
&& palletOptions.every(palletSnapshotComplete)
|
||||||
context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse,
|
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/)
|
||||||
|
})
|
||||||
+72
@@ -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}过期托盘/)
|
||||||
|
})
|
||||||
@@ -232,7 +232,9 @@ Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin
|
|||||||
$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js')
|
$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js')
|
||||||
$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.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')
|
$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 $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游'
|
||||||
Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API'
|
Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API'
|
||||||
Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化'
|
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 '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览'
|
||||||
Assert-Contains $receivingController 'CacheControl.noStore()' '海外 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 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
|
# Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。
|
||||||
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
|
[xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw
|
||||||
foreach ($module in $pom.project.modules.module) {
|
foreach ($module in $pom.project.modules.module) {
|
||||||
@@ -312,6 +329,8 @@ foreach ($module in $pom.project.modules.module) {
|
|||||||
# 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。
|
# 对本轮高变化链路做精确文件门禁,防止改名或删除后留下悬空索引。
|
||||||
$criticalPaths = @(
|
$criticalPaths = @(
|
||||||
'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts',
|
'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/inboundPlans.ts',
|
||||||
'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts',
|
'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts',
|
||||||
'apps/admin-web/src/modules/overseas-warehouse/api/pallets.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/vite.config.js',
|
||||||
'apps/pda-uniapp/utils/app-update.js',
|
'apps/pda-uniapp/utils/app-update.js',
|
||||||
'apps/pda-uniapp/tests/app-update.test.mjs',
|
'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-uniapp/utils/evidence-upload-queue.js',
|
||||||
'apps/pda-overseas-uniapp/package.json',
|
'apps/pda-overseas-uniapp/package.json',
|
||||||
'apps/pda-overseas-uniapp/src/pages.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/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/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/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/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/inboundplan/pda/gateway/UnloadPhotoFileGateway.java',
|
||||||
'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.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("warehouseId") long warehouseId,
|
||||||
@Param("limit") int limit);
|
@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("""
|
||||||
INSERT INTO wh_location (
|
INSERT INTO wh_location (
|
||||||
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
|
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,
|
int release(@Param("tenantId") long tenantId, @Param("id") long id,
|
||||||
@Param("version") int version, @Param("reason") String reason);
|
@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("""
|
||||||
UPDATE wh_location_reservation
|
UPDATE wh_location_reservation
|
||||||
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
|
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ public class LocationRepository {
|
|||||||
.eq(LocationEntity::getId, id));
|
.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) {
|
public PageResult<LocationEntity> page(Long tenantId, PageQuery query) {
|
||||||
Page<LocationEntity> result = locations.selectLocationPage(new Page<>(query.page(), query.size()),
|
Page<LocationEntity> result = locations.selectLocationPage(new Page<>(query.page(), query.size()),
|
||||||
tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status());
|
tenantId, query.warehouseId(), query.zoneCode(), query.locationCode(), query.locationType(), query.status());
|
||||||
@@ -94,6 +98,10 @@ public class LocationRepository {
|
|||||||
|| occupancies.countActiveByLocation(tenantId, locationId) > 0;
|
|| 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,
|
public List<LocationReservationEntity> findReservationsByKeys(long tenantId, String sourceService,
|
||||||
Collection<String> keys) {
|
Collection<String> keys) {
|
||||||
if (keys.isEmpty()) {
|
if (keys.isEmpty()) {
|
||||||
@@ -129,6 +137,10 @@ public class LocationRepository {
|
|||||||
return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
|
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,
|
public int transferConfirmedReservation(LocationReservationEntity entity, String sourceService,
|
||||||
long sourceBusinessId, long targetBusinessId,
|
long sourceBusinessId, long targetBusinessId,
|
||||||
String targetInboundCode) {
|
String targetInboundCode) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import com.baiyue.warehouse.entity.LocationReservationEntity;
|
|||||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
import com.baiyue.warehouse.repository.LocationRepository;
|
import com.baiyue.warehouse.repository.LocationRepository;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -42,6 +44,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +54,8 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
@Validated
|
@Validated
|
||||||
public class LocationResourceService {
|
public class LocationResourceService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LocationResourceService.class);
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private LocationRepository repository;
|
private LocationRepository repository;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -165,13 +170,8 @@ public class LocationResourceService {
|
|||||||
if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
|
if (!"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
|
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
|
||||||
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, "释放占用时库位状态已被并发修改");
|
|
||||||
}
|
|
||||||
released.add(active(occupancy, "RELEASED", null));
|
released.add(active(occupancy, "RELEASED", null));
|
||||||
}
|
}
|
||||||
for (LocationReservationEntity reservation : repository.findReservationsByBusiness(
|
for (LocationReservationEntity reservation : repository.findReservationsByBusiness(
|
||||||
@@ -237,15 +237,8 @@ public class LocationResourceService {
|
|||||||
List<SelectiveReleaseResult> released = new ArrayList<>(selectedIndexes.size());
|
List<SelectiveReleaseResult> released = new ArrayList<>(selectedIndexes.size());
|
||||||
for (int index : selectedIndexes) {
|
for (int index : selectedIndexes) {
|
||||||
LocationOccupancyEntity occupancy = active.get(index);
|
LocationOccupancyEntity occupancy = active.get(index);
|
||||||
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
|
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
|
||||||
occupancy.getLocationId());
|
"Location occupancy changed concurrently while releasing selected locations");
|
||||||
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");
|
|
||||||
}
|
|
||||||
released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
released.add(new SelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
||||||
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
|
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
|
||||||
occupancy.getLocationCode(), packages.get(index)));
|
occupancy.getLocationCode(), packages.get(index)));
|
||||||
@@ -289,15 +282,8 @@ public class LocationResourceService {
|
|||||||
throw new BizException(ResultCode.CONFLICT,
|
throw new BizException(ResultCode.CONFLICT,
|
||||||
"Occupied location has no valid allocated package count and cannot be released exactly");
|
"Occupied location has no valid allocated package count and cannot be released exactly");
|
||||||
}
|
}
|
||||||
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
|
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(),
|
||||||
occupancy.getLocationId());
|
"Location occupancy changed concurrently while releasing selected locations");
|
||||||
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");
|
|
||||||
}
|
|
||||||
released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
||||||
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
|
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
|
||||||
occupancy.getLocationCode(), allocatedPackageCount));
|
occupancy.getLocationCode(), allocatedPackageCount));
|
||||||
@@ -322,19 +308,94 @@ public class LocationResourceService {
|
|||||||
LocationOccupancyEntity occupancy = active.get(index);
|
LocationOccupancyEntity occupancy = active.get(index);
|
||||||
if (!requested.contains(occupancy.getReservationId())) continue;
|
if (!requested.contains(occupancy.getReservationId())) continue;
|
||||||
Integer allocated = occupancy.getAllocatedPackageCount();
|
Integer allocated = occupancy.getAllocatedPackageCount();
|
||||||
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
|
if (allocated == null || allocated < 0) {
|
||||||
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)) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更");
|
throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更");
|
||||||
}
|
}
|
||||||
|
releaseOccupiedResource(occupancy, command.reason(), command.operatorId(), "释放库位资源时发生并发变更");
|
||||||
result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
|
||||||
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated));
|
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated));
|
||||||
}
|
}
|
||||||
return List.copyOf(result);
|
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),
|
location_type=VALUES(location_type),
|
||||||
max_weight_kg=VALUES(max_weight_kg),
|
max_weight_kg=VALUES(max_weight_kg),
|
||||||
shared_overflow=VALUES(shared_overflow),
|
shared_overflow=VALUES(shared_overflow),
|
||||||
status='AVAILABLE',
|
remark=VALUES(remark);
|
||||||
remark=VALUES(remark),
|
|
||||||
deleted=0;
|
|
||||||
|
|
||||||
INSERT INTO wh_location(
|
INSERT INTO wh_location(
|
||||||
id,tenant_id,warehouse_id,zone_code,zone_name,location_code,location_name,
|
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),
|
location_type=VALUES(location_type),
|
||||||
max_weight_kg=VALUES(max_weight_kg),
|
max_weight_kg=VALUES(max_weight_kg),
|
||||||
shared_overflow=VALUES(shared_overflow),
|
shared_overflow=VALUES(shared_overflow),
|
||||||
status='AVAILABLE',
|
remark=VALUES(remark);
|
||||||
remark=VALUES(remark),
|
|
||||||
deleted=0;
|
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
|
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
|
||||||
DELIMITER //
|
DELIMITER //
|
||||||
|
|||||||
+8
@@ -27,6 +27,9 @@ class InitialWarehouseResourceSeedSqlContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
|
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)
|
assertThat(sql)
|
||||||
.contains("0," + yiwuId + ",'TP-001','木托盘'")
|
.contains("0," + yiwuId + ",'TP-001','木托盘'")
|
||||||
.contains("0," + dongguanId + ",'TP-002','塑料托盘'")
|
.contains("0," + dongguanId + ",'TP-002','塑料托盘'")
|
||||||
@@ -52,5 +55,10 @@ class InitialWarehouseResourceSeedSqlContractTest {
|
|||||||
.contains("'AVAILABLE'")
|
.contains("'AVAILABLE'")
|
||||||
.doesNotContain("INSERT INTO wh_location_reservation")
|
.doesNotContain("INSERT INTO wh_location_reservation")
|
||||||
.doesNotContain("INSERT INTO wh_location_occupancy");
|
.doesNotContain("INSERT INTO wh_location_occupancy");
|
||||||
|
assertThat(locationSeed)
|
||||||
|
.doesNotContain("status='AVAILABLE'")
|
||||||
|
.doesNotContain("status = 'AVAILABLE'")
|
||||||
|
.doesNotContain("deleted=0")
|
||||||
|
.doesNotContain("deleted = 0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-20
@@ -257,9 +257,7 @@ class LocationResourceServiceTest {
|
|||||||
item.setBusinessId(901L);
|
item.setBusinessId(901L);
|
||||||
});
|
});
|
||||||
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
|
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
|
||||||
when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
|
stubSuccessfulOccupiedRelease(active.get(1), occupiedLocation(10L, "A2"), "PDA partial pick", 7L);
|
||||||
when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1);
|
|
||||||
when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
|
|
||||||
|
|
||||||
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
|
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
|
||||||
"PDA partial pick", 10, List.of(10L), null));
|
"PDA partial pick", 10, List.of(10L), null));
|
||||||
@@ -280,9 +278,7 @@ class LocationResourceServiceTest {
|
|||||||
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
|
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
|
||||||
second.setAllocatedPackageCount(7);
|
second.setAllocatedPackageCount(7);
|
||||||
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
|
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
|
||||||
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
|
stubSuccessfulOccupiedRelease(second, occupiedLocation(10L, "A2"), "PDA full pick", 7L);
|
||||||
when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1);
|
|
||||||
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
|
|
||||||
|
|
||||||
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
|
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
|
||||||
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
|
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
|
||||||
@@ -299,9 +295,7 @@ class LocationResourceServiceTest {
|
|||||||
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
|
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
|
||||||
occupancy.setAllocatedPackageCount(0);
|
occupancy.setAllocatedPackageCount(0);
|
||||||
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
|
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
|
||||||
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
|
stubSuccessfulOccupiedRelease(occupancy, occupiedLocation(9L, "A1"), "PDA full pick", 7L);
|
||||||
when(repository.releaseOccupancy(occupancy, "PDA full pick")).thenReturn(1);
|
|
||||||
when(repository.changeStatus(10001L, 88L, 9L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
|
|
||||||
|
|
||||||
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
|
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
|
||||||
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(9L)));
|
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);
|
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
|
||||||
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
|
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
|
||||||
when(repository.findOwned(0L, 88L, item.getLocationId()))
|
stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()),
|
||||||
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
|
"PDA default pick", 7L);
|
||||||
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
|
|
||||||
when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
|
|
||||||
.thenReturn(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 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);
|
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active);
|
||||||
for (int index = 0; index < 2; index++) {
|
for (int index = 0; index < 2; index++) {
|
||||||
LocationOccupancyEntity item = active.get(index);
|
LocationOccupancyEntity item = active.get(index);
|
||||||
when(repository.findOwned(10001L, 88L, item.getLocationId()))
|
stubSuccessfulOccupiedRelease(item, occupiedLocation(item.getLocationId(), item.getLocationCode()),
|
||||||
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
|
"PDA default pick", 7L);
|
||||||
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
|
|
||||||
when(repository.changeStatus(10001L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
|
|
||||||
.thenReturn(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
|
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
|
||||||
@@ -379,7 +367,8 @@ class LocationResourceServiceTest {
|
|||||||
void shouldFailOnConcurrentOccupancyUpdate() {
|
void shouldFailOnConcurrentOccupancyUpdate() {
|
||||||
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
|
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
|
||||||
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
|
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);
|
when(repository.releaseOccupancy(active, "PDA pick")).thenReturn(0);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand(
|
assertThatThrownBy(() -> service.releaseSelected(new SelectiveReleaseCommand(
|
||||||
@@ -389,6 +378,64 @@ class LocationResourceServiceTest {
|
|||||||
any(String.class), any(String.class), anyInt(), anyLong());
|
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
|
@Test
|
||||||
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
|
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
|
||||||
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
|
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
|
||||||
@@ -514,4 +561,36 @@ class LocationResourceServiceTest {
|
|||||||
reservation.setVersion(0);
|
reservation.setVersion(0);
|
||||||
return reservation;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user