Compare commits

...
35 Commits
Author SHA1 Message Date
wujinliang 3b1b4ab9b2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-27 21:49:00 +08:00
wujinliang 9d54c57b7c 1 2026-08-27 21:48:47 +08:00
ouyangxq e72ac14425 fix: map warehouse pallet lookup failure 2026-08-27 19:31:38 +08:00
ouyangxq ec8f84c1c0 fix: reject missing receiving pallet 2026-08-27 19:27:59 +08:00
ouyangxq 2b7f66cb04 fix: reclaim newly added container plans 2026-08-27 19:14:54 +08:00
ouyangxq c7061b8a74 优化 2026-08-27 18:18:14 +08:00
ouyangxq 5250f304be 优化 2026-08-27 18:11:57 +08:00
ouyangxq f5f72d681d 优化 2026-08-27 18:05:02 +08:00
ouyangxq c226b6a99b 优化 2026-08-27 17:49:02 +08:00
ouyangxq 600b2a2dc4 优化 2026-08-27 17:31:19 +08:00
ouyangxq 76c24867a4 优化 2026-08-27 17:16:33 +08:00
ouyangxq 61a97cd057 优化 2026-08-27 16:42:27 +08:00
ouyangxq ecf48886d5 优化 2026-08-27 16:34:30 +08:00
ouyangxq 1db3e48506 优化 2026-08-27 16:17:11 +08:00
ouyangxq 0f2e201c38 fix(lastleg): unblock inbound plan handoff 2026-08-27 15:56:14 +08:00
ouyangxq adabec13d3 fix(print): include Spring DAO exceptions at runtime 2026-08-27 15:21:28 +08:00
ouyangxq 0fb51211aa 优化 2026-08-27 15:02:37 +08:00
ouyangxq d7bff980ca 优化 2026-08-27 14:53:27 +08:00
ouyangxq ed3c1dafcc 优化 2026-08-27 14:30:33 +08:00
ouyangxq 0a3ec0b8a6 优化 2026-08-27 14:30:23 +08:00
ouyangxq 3b38fdd33d 优化 2026-08-27 13:50:33 +08:00
ouyangxq daf0ee2acd 优化 2026-08-27 11:45:52 +08:00
ouyangxq 086a0f9c89 优化 2026-08-27 11:42:05 +08:00
ouyangxq c80307a838 优化 2026-08-27 11:39:32 +08:00
ouyangxq 575cee7a86 优化 2026-08-27 11:38:53 +08:00
ouyangxq c1b136c3d6 优化 2026-08-27 11:09:51 +08:00
ouyangxq 4612181ab0 优化 2026-08-27 11:07:57 +08:00
ouyangxq b4c46280de test(acceptance): cover booking agent carrier matrix 2026-08-27 10:38:55 +08:00
ouyangxq 15b563610c fix(firstleg): validate booking carrier associations 2026-08-27 10:30:38 +08:00
ouyangxq 079ff43f73 feat(warehouse): support supplier carrier associations 2026-08-27 10:29:28 +08:00
ouyangxq 554b6c93b7 优化 2026-08-27 09:21:01 +08:00
ouyangxq 817e356c21 Merge remote-tracking branch 'origin/dev' into test 2026-08-26 21:17:25 +08:00
ouyangxq ca12f58d38 chore: remove tracked runtime artifacts 2026-08-26 20:51:42 +08:00
ouyangxq 8fb63e9adc Merge remote-tracking branch 'origin/dev' into test
# Conflicts:
#	warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java
#	warehouse/src/main/resources/db/warehouse.sql
2026-08-26 20:51:27 +08:00
wujinliang d736fdba1b 1 2026-08-26 18:12:55 +08:00
197 changed files with 6000 additions and 516 deletions
Binary file not shown.
-39
View File
@@ -1,39 +0,0 @@
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Statement;
public final class DbQuery {
public static void main(String[] args) throws Exception {
if (args.length < 2) {
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
}
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
+ "&useSSL=false&allowPublicKeyRetrieval=true";
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
Statement statement = connection.createStatement()) {
boolean hasResult = statement.execute(args[1]);
if (!hasResult) {
System.out.println("updateCount=" + statement.getUpdateCount());
return;
}
try (ResultSet result = statement.getResultSet()) {
ResultSetMetaData meta = result.getMetaData();
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(meta.getColumnLabel(column));
}
System.out.println();
while (result.next()) {
for (int column = 1; column <= meta.getColumnCount(); column++) {
if (column > 1) System.out.print("\t");
System.out.print(String.valueOf(result.getObject(column)));
}
System.out.println();
}
}
}
}
}
+1
View File
@@ -40,6 +40,7 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 待分配柜级摘要与整柜入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合分页;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等;详见 `lastleg-service/AGENTS.md` |
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -7,7 +7,15 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) {
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId,
String warehouseName, List<Order> orders) {
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
warehouseName, null, null, null, orders);
}
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +23,18 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
BigDecimal volumeWeightKg,
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) {
public Order {
items = items == null ? List.of() : items;
if (customerId == null || customerId.isBlank()) {
throw new IllegalArgumentException("customerId is required");
}
if (customerName == null || customerName.isBlank()) {
throw new IllegalArgumentException("customerName is required");
}
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
@@ -30,11 +45,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, null);
}
public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +60,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
List<Item> items, String customerId, String customerName,
String orderType, List<PalletSnapshot> pallets, String destinationType) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
orderType, pallets, destinationType);
}
}
@@ -0,0 +1,42 @@
openapi: 3.1.0
info:
title: Baiyue Cross-region Event Contracts
version: 1.0.0
paths: {}
components:
schemas:
FirstLegLoadingPlanCompletedV2:
type: object
required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
properties:
loadingPlanId: {type: string}
containerBookingId: {type: string}
containerNo: {type: string}
cabinetNo: {type: string}
warehouseId: {type: string}
warehouseName: {type: string}
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
orders:
type: array
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
FirstLegLoadingPlanCompletedOrderV2:
type: object
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
properties:
sourceInboundId: {type: string}
sourceOrderId: {type: string}
sourceOrderNo: {type: string}
inboundCode: {type: string}
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
packageCount: {type: integer, minimum: 1}
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
items: {type: array, items: {type: object}}
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
pallets: {type: array, items: {type: object}}
destinationType: {type: string, enum: [FBA, OVERSEAS]}
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
new BigDecimal("0.200"), List.of(item));
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
""";
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
assertThat(restored.actualPortArrivalAt()).isNull();
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
}
}
@@ -4,17 +4,43 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH",
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
}
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
null, null, null, 1, 2, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
@@ -29,13 +55,14 @@ class FirstLegLoadingPlanCompletedV2Test {
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
@@ -44,7 +71,7 @@ class FirstLegLoadingPlanCompletedV2Test {
void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
@@ -58,7 +85,8 @@ class FirstLegLoadingPlanCompletedV2Test {
20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 2, 20, null, null, null, null, List.of(item));
null, null, null, 2, 20, null, null, null, null, List.of(item),
"11", "客户甲", "FIRST_LEG", List.of());
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
@@ -66,4 +94,29 @@ class FirstLegLoadingPlanCompletedV2Test {
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
}
@Test
void rejectsMissingImmutableCustomerName() {
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
}
@Test
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
String yaml;
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
"/openapi/cross-region-events-v1.yaml")) {
assertThat(input).isNotNull();
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(yaml)
.contains("required: [sourceInboundId, customerId, customerName")
.contains("customerId: {type: string, minLength: 1")
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
.contains("consumers must not query domestic IAM")
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
}
}
+4
View File
@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
@@ -96,6 +97,14 @@ public class GlobalExceptionHandler {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(CannotAcquireLockException.class)
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
CannotAcquireLockException exception, HttpServletRequest request) {
log.warn("Database lock acquisition conflict requestId={} traceId={}",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -16,6 +16,7 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.dao.CannotAcquireLockException;
import java.lang.reflect.Method;
@@ -116,6 +117,18 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
}
@Test
void lockWaitTimeoutMapsToCanonicalConflict() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
var response = handler.handleCannotAcquireLock(
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
}
private void paging(long page) {
}
}
@@ -5365,7 +5365,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -5378,7 +5378,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
@@ -5618,7 +5618,6 @@ BEGIN
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
@@ -6473,9 +6472,7 @@ ON DUPLICATE KEY UPDATE
location_type = VALUES(location_type),
max_weight_kg = VALUES(max_weight_kg),
shared_overflow = VALUES(shared_overflow),
status = 'AVAILABLE',
remark = VALUES(remark),
deleted = 0;
remark = VALUES(remark);
INSERT INTO wh_location (
id, tenant_id, warehouse_id, zone_code, zone_name, location_code, location_name,
@@ -6494,9 +6491,7 @@ ON DUPLICATE KEY UPDATE
location_type = VALUES(location_type),
max_weight_kg = VALUES(max_weight_kg),
shared_overflow = VALUES(shared_overflow),
status = 'AVAILABLE',
remark = VALUES(remark),
deleted = 0;
remark = VALUES(remark);
DROP PROCEDURE IF EXISTS assert_yiwu_location_seed;
DELIMITER //
@@ -13772,7 +13767,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'loading_pl
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_at', '开始时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by', '开始装柜人');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'started_by_name', '开始装柜人名称');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'task_status', 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_task', 'version', '乐观锁版本号');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_at', '确认时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'confirmed_by', '确认操作人ID');
@@ -15633,9 +15628,11 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history (
id BIGINT UNSIGNED NOT NULL COMMENT '运输字段变更历史ID',container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
before_container_no VARCHAR(64) NULL COMMENT '变更前真实集装箱号',after_container_no VARCHAR(64) NULL COMMENT '变更后真实集装箱号',
before_voyage_no VARCHAR(64) NULL COMMENT '变更前航次号',after_voyage_no VARCHAR(64) NULL COMMENT '变更后航次号',
operation_id VARCHAR(64) NOT NULL COMMENT '运输字段操作幂等ID',request_hash CHAR(64) NOT NULL COMMENT '运输字段请求哈希',
reason VARCHAR(500) NULL COMMENT '运输字段变更原因',operator_id BIGINT UNSIGNED NOT NULL COMMENT '变更操作人ID',
operator_name VARCHAR(64) NULL COMMENT '变更操作人名称快照',created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间',
PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id)
PRIMARY KEY(id),UNIQUE KEY uk_container_transport_history_operation(container_booking_id,operator_id,operation_id),
KEY idx_container_transport_history(container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
ALTER TABLE baiyue_firstleg.fl_container_transport_history
@@ -15645,6 +15642,8 @@ ALTER TABLE baiyue_firstleg.fl_container_transport_history
MODIFY COLUMN after_container_no VARCHAR(64) NULL COMMENT '变更后真实集装箱号',
MODIFY COLUMN before_voyage_no VARCHAR(64) NULL COMMENT '变更前航次号',
MODIFY COLUMN after_voyage_no VARCHAR(64) NULL COMMENT '变更后航次号',
MODIFY COLUMN operation_id VARCHAR(64) NOT NULL COMMENT '运输字段操作幂等ID',
MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT '运输字段请求哈希',
MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '运输字段变更原因',
MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '变更操作人ID',
MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '变更操作人名称快照',
@@ -15960,6 +15959,14 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
CREATE TABLE IF NOT EXISTS wh_supplier_carrier (
supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码', created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY(supplier_id,carrier_code), KEY idx_wh_supplier_carrier_code(tenant_id,carrier_code,supplier_id),
CONSTRAINT chk_wh_supplier_carrier_tenant CHECK(tenant_id=0),
CONSTRAINT chk_wh_supplier_carrier_code CHECK(carrier_code IN('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联';
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64),IN p_definition TEXT)
@@ -16021,6 +16028,8 @@ CREATE PROCEDURE assert_platform_supplier_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_supplier WHERE tenant_id<>0)<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商存在非平台作用域数据'; END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表索引不完整'; END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND index_name IN('PRIMARY','idx_wh_supplier_carrier_code'))<>2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商船司关联表索引不完整'; END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND constraint_type='CHECK' AND constraint_name IN('chk_wh_supplier_carrier_tenant','chk_wh_supplier_carrier_code'))<>2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商船司关联表约束不完整'; END IF;
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; END IF;
END$$
@@ -16496,6 +16505,69 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态';
USE baiyue_firstleg;
ALTER TABLE fl_loading_pick_task MODIFY COLUMN task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'
COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED';
ALTER TABLE fl_loading_pick_task DROP CHECK chk_loading_pick_task_status;
ALTER TABLE fl_loading_pick_task ADD CONSTRAINT chk_loading_pick_task_status
CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'));
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '关联PDA任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
command_status VARCHAR(16) NOT NULL COMMENT '命令状态',
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
remark VARCHAR(500) NULL COMMENT '装柜完成备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '后台认领次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许处理时间',
lease_owner VARCHAR(128) NULL COMMENT '后台租约持有者',
lease_until DATETIME(3) NULL COMMENT '后台租约截止时间',
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
failure_code VARCHAR(32) NULL COMMENT '业务或下游错误码',
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令更新时间',
completed_at DATETIME(3) NULL COMMENT '命令完成时间',
PRIMARY KEY(id),UNIQUE KEY uk_loading_completion_operation(operation_id),
KEY idx_loading_completion_dispatch(command_status,next_retry_at,lease_until),
CONSTRAINT fk_loading_completion_task FOREIGN KEY(pick_task_id) REFERENCES fl_loading_pick_task(id),
CONSTRAINT chk_loading_completion_status CHECK(command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入库单客户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序',
checkpoint_stage VARCHAR(24) NOT NULL COMMENT '入库单处理检查点',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间',
PRIMARY KEY(command_id,tenant_id,inbound_id),KEY idx_loading_completion_owner(command_id,owner_sequence),
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY(command_id) REFERENCES fl_loading_completion_command(id),
CONSTRAINT chk_loading_completion_checkpoint CHECK(checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update;
DELIMITER $$
CREATE TRIGGER trg_loading_pick_task_before_update BEFORE UPDATE ON fl_loading_pick_task FOR EACH ROW
BEGIN
IF OLD.task_status='COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已完成PDA拣货任务不可修改'; END IF;
IF NEW.loading_plan_id<>OLD.loading_plan_id OR NOT(NEW.container_booking_id<=>OLD.container_booking_id)
OR NOT(NEW.cabinet_no<=>OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务稳定归属不可修改';
END IF;
IF NOT ((OLD.task_status='LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
OR (OLD.task_status='COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
OR (OLD.task_status='COMPLETION_FAILED' AND NEW.task_status='COMPLETING')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PDA拣货任务状态流转非法';
END IF;
END$$
DELIMITER ;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
@@ -498,6 +498,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
@@ -61,6 +61,15 @@ public class PlatformFinanceController {
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String direction,
@RequestParam(required = false) Long supplierId) {
return FinanceResponses.success(queryService.feePool(customerId, direction, supplierId));
}
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId,
@@ -118,7 +118,8 @@ public class FinancePhase1OperationService {
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
}
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedReview = normalized(reviewStatus,
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
@@ -99,7 +99,12 @@ public class FinanceQueryService {
}
public List<FeePoolItem> feePool(Long customerId, String direction) {
return feePool(customerId, direction, null);
}
public List<FeePoolItem> feePool(Long customerId, String direction, Long supplierId) {
validateOptionalCustomer(customerId);
if (supplierId != null && supplierId <= 0) throw bad("供应商范围无效");
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
@@ -107,6 +112,14 @@ public class FinanceQueryService {
List<Object> sourceArgs = new ArrayList<>();
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
if ("PAYABLE".equals(normalized)) {
sourceSql += " AND review_status='APPROVED' AND settlement_mode='ON_ACCOUNT' AND currency_code='CNY' AND amount>0";
sourceSql += " AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id WHERE sl.fee_source_id=fin_fee_source.id AND s.status<>'VOIDED')";
if (supplierId != null) {
sourceSql += " AND CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?";
sourceArgs.add(supplierId);
}
}
sourceSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction");
@@ -39,14 +39,35 @@ class FinanceFeePoolDirectionBehaviorTest {
assertThat(jdbc.costQueryCount).isZero();
}
@Test
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
service.feePool(7L, "PAYABLE", 81L);
assertThat(jdbc.sourceSql).contains(
"review_status='APPROVED'",
"settlement_mode='ON_ACCOUNT'",
"currency_code='CNY'",
"NOT EXISTS (SELECT 1 FROM fin_settlement_line",
"s.status<>'VOIDED'",
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?");
assertThat(jdbc.sourceArgs).containsExactly(7L, "PAYABLE", 81L);
}
private static final class DirectionJdbc extends JdbcTemplate {
private final List<String> sourceDirections = new ArrayList<>();
private int costQueryCount;
private String sourceSql;
private List<Object> sourceArgs = List.of();
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
if (sql.contains("FROM fin_fee_source")) {
sourceSql = sql;
sourceArgs = List.of(args);
String direction = String.valueOf(args[1]);
sourceDirections.add(direction);
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -16,8 +17,17 @@ import java.util.List;
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1")
public interface WarehousePalletLocationClient {
@GetMapping("/pallets/snapshots")
ApiResponse<List<PalletSnapshot>> palletSnapshots(
ApiResponse<PageResult<PalletSnapshot>> palletSnapshots(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("warehouseId") long warehouseId,
@RequestParam("page") long page,
@RequestParam("size") long size,
@RequestParam(value = "keyword", required = false) String keyword);
@GetMapping("/pallets/{palletId}/snapshot")
ApiResponse<PalletSnapshot> palletSnapshot(
@RequestHeader("X-Internal-Service-Token") String token,
@org.springframework.web.bind.annotation.PathVariable("palletId") long palletId,
@RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations")
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehousePartnerClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse-partners")
public interface WarehousePartnerClient {
@GetMapping("/{tenantId}/{partnerId}/active")
ApiResponse<Snapshot> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId, @PathVariable long partnerId);
record Snapshot(long id, long tenantId, String partnerName, String partnerType,
String carrierCode, List<String> types, String status) {}
}
@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
@@ -16,5 +17,11 @@ public interface WarehouseSupplierClient {
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam(required = false) String category);
record Snapshot(long supplierId, String name, String category, String status) {}
@GetMapping
ApiResponse<List<Snapshot>> candidates(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam String category,
@RequestParam String carrierCode);
record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
}
@@ -114,9 +114,10 @@ public class PlatformContainerController {
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody TransportUpdateRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), key, request));
}
@GetMapping("/{id}/clearance")
@@ -4,6 +4,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
@@ -18,8 +19,12 @@ import com.baiyue.firstleg.service.MeasurementSubmissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -33,10 +38,10 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@Validated
@RestController
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
public class PlatformInboundMeasurementController {
@@ -56,16 +61,19 @@ public class PlatformInboundMeasurementController {
}
@Operation(summary = "查询称重可选托盘快照",
description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。")
description = "按入库单所属仓库分页返回当前有效托盘候选,支持编码或类型关键词;托盘可复用,不按预占或占用过滤。")
@GetMapping("/weight-pallet-options")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')")
public ApiResponse<List<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
public ApiResponse<PageResult<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
@PathVariable long inboundId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
@RequestParam(required = false) @Size(max = 50) String keyword) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId));
return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId, page, size, keyword));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
@@ -200,6 +200,7 @@ public final class LoadingListDtos {
public record History(
Long historyId,
Long itemId,
String operationId,
String actionType,
String fromStatus,
String toStatus,
@@ -125,12 +125,18 @@ public final class LoadingPickDtos {
Long operatorId, String operatorName, LocalDateTime createdAt) {
}
public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark,
Integer attemptCount, LocalDateTime nextRetryAt, String failureStage,
String failureCode, String requestId, String traceId,
LocalDateTime completedAt) {
}
public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId,
Integer inboundVersion, Integer loadingVersion, String cabinetNo,
String currentContainerNo, String sourceType, String recordStatus,
Long mergedIntoContainerBookingId, String taskStatus,
String workflowStatus, Integer version, Assignment assignment,
Review review, List<PickItem> items) {
Review review, CompletionCommand completionCommand, List<PickItem> items) {
}
/** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */
@@ -1,5 +1,6 @@
package com.baiyue.firstleg.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
@@ -69,6 +70,8 @@ public class ContainerBookingEntity {
@TableField(exist = false) private Long planId;
@TableField(exist = false) private String planNo;
@TableField(exist = false) private String planStatus;
/** Actual completion timestamp projected from the associated loading plan. */
@JsonIgnore @TableField(exist = false) private LocalDateTime completedLoadingAt;
@TableField(exist = false) private Boolean exceptionActive;
@TableField(exist = false) private String destinationClearanceDocumentStatus;
@TableField(exist = false) private String customsDeclarationDocumentStatus;
@@ -16,6 +16,8 @@ public class ContainerTransportHistoryEntity {
private String afterContainerNo;
private String beforeVoyageNo;
private String afterVoyageNo;
private String operationId;
private String requestHash;
private String reason;
private Long operatorId;
private String operatorName;
@@ -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;
}
@@ -42,7 +42,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
@Update("UPDATE fl_preload_plan SET status='CONFIRMED',confirmed_by=#{operatorId},"
+ "confirmed_by_name=#{operatorName},confirmed_at=NOW(),updated_by=#{operatorId},"
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{preloadPlanId} "
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status='PENDING' "
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status IN ('PENDING','CONFIRMING') "
+ "AND version=#{expectedVersion}")
int confirmIncrementalPreload(@Param("preloadPlanId") long preloadPlanId,
@Param("tenantId") long tenantId,
@@ -125,7 +125,9 @@ public class LoadingModelBridgeService {
}
var preload = repository.preload(tenantId, inboundId);
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) {
boolean running = "LOADING".equals(plan.status());
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())
&& !(running && "CONFIRMING".equals(preload.status()))) {
throw conflict("新增入库单预装状态不允许进入当前柜次");
}
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
@@ -145,7 +147,7 @@ public class LoadingModelBridgeService {
// A running PDA task has already passed plan-level confirmation. Adding an inbound is the
// explicit Admin approval for that new allocation, so its fresh PENDING preload joins the
// same confirmed plan atomically instead of leaving an unreachable pending aggregate.
if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status())
if (running && Set.of("PENDING", "CONFIRMING").contains(preload.status())
&& repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId,
preload.version(), operatorId, operatorName) != 1) {
throw conflict("新增入库单预装确认并发冲突,请刷新后重试");
@@ -61,6 +61,17 @@ public class LoadingRepository {
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public LoadingEvidenceEntity findActiveEvidenceByFile(long tenantId, long inboundId,
String evidenceType, long fileId) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
.eq(LoadingEvidenceEntity::getEvidenceType, evidenceType)
.eq(LoadingEvidenceEntity::getFileId, fileId)
.eq(LoadingEvidenceEntity::getStatus, "ACTIVE")
.last("LIMIT 1"));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int voidEvidence(long tenantId, long inboundId, long evidenceId, String reason,
@@ -34,6 +34,7 @@ import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -74,6 +75,18 @@ public class LoadingDeliveryService {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
}
LoadingEvidenceEntity existingFile = repository.findActiveEvidenceByFile(
tenantId, inboundId, request.evidenceType(), request.fileId());
if (existingFile != null) {
String requestedSealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType())
? request.sealNo().trim() : null;
if (!Objects.equals(existingFile.getSealNo(), requestedSealNo)) {
throw new BizException(ResultCode.CONFLICT, "该带锁码照已绑定其他锁码,请先作废后重新拍摄");
}
LoadingActionResponse result = new LoadingActionResponse(
requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
}
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
@@ -103,23 +116,33 @@ public class LoadingDeliveryService {
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName,
key, request, false);
}
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
boolean acceptedPdaCompletion) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING,
key, hash, acceptedPdaCompletion);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target);
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName,
key, request, target, acceptedPdaCompletion);
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
loadingModelBridge.requireCompleted(tenantId, inboundId);
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
@@ -132,12 +155,14 @@ public class LoadingDeliveryService {
* Resolve and lock the canonical completion row after settlement, then retain optimistic locking
* with that current server version instead of reusing the pre-settlement task snapshot.
*/
@Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long operatorId, String operatorName,
String key, String remark) {
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
}
@Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long evidenceInboundId, long operatorId,
String operatorName, String key, String remark) {
@@ -148,7 +173,7 @@ public class LoadingDeliveryService {
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
}
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key,
new CompleteLoadingRequest(current.version(), remark));
new CompleteLoadingRequest(current.version(), remark), true);
}
/**
@@ -172,7 +197,8 @@ public class LoadingDeliveryService {
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
CompletionTarget target) {
CompletionTarget target,
boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) {
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId)));
@@ -180,9 +206,9 @@ public class LoadingDeliveryService {
if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
}
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId());
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
@@ -295,9 +321,21 @@ public class LoadingDeliveryService {
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private void requireCompletedPicking(long tenantId, long inboundId) {
if (!canonicalPicking(tenantId, inboundId).completed()) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
/** worker 先完成各入库单,之后才完成柜次计划;这里不能循环要求计划已经 COMPLETED。 */
private void requireLoadingPlanGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
if (acceptedPdaCompletion) {
loadingModelBridge.requireStarted(tenantId, inboundId);
return;
}
loadingModelBridge.requireCompleted(tenantId, inboundId);
}
/** completion worker 已持有 durable lease;此时 task 尚为 COMPLETING,不能循环要求最终 COMPLETED。 */
private void requirePickingGate(long tenantId, long inboundId, boolean acceptedPdaCompletion) {
CanonicalPicking picking = canonicalPicking(tenantId, inboundId);
if (acceptedPdaCompletion ? !picking.resolved() : !picking.completed()) {
throw new BizException(ResultCode.CONFLICT, acceptedPdaCompletion
? "PDA实装明细尚未全部解决" : "PDA拣货复核尚未完成");
}
}
@@ -324,9 +362,12 @@ public class LoadingDeliveryService {
private record CanonicalPicking(int expectedCount, int pickedCount, int taskCount,
int completedTaskCount, int unresolvedCount) {
boolean completed() {
boolean resolved() {
return expectedCount > 0 && expectedCount == pickedCount && taskCount > 0
&& completedTaskCount == taskCount && unresolvedCount == 0;
&& unresolvedCount == 0;
}
boolean completed() {
return resolved() && completedTaskCount == taskCount;
}
}
@@ -418,10 +459,17 @@ public class LoadingDeliveryService {
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash) {
return claimOrReplay(tenantId, inboundId, operatorId, operation, key, hash, false);
}
private LoadingActionResponse claimOrReplay(long tenantId, long inboundId, long operatorId,
String operation, String key, String hash,
boolean resumeProcessing) {
validateKey(key);
if (repository.claimOperation(tenantId, inboundId, operatorId, operation, key, hash)) return null;
LoadingOperationEntity existing = repository.findOperation(tenantId, inboundId, operatorId, operation, key);
if (existing == null || !hash.equals(existing.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求");
if (resumeProcessing && "PROCESSING".equals(existing.getStatus())) return null;
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
try { return objectMapper.readValue(existing.getResultJson(), LoadingActionResponse.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法读取装柜幂等结果", e); }
@@ -16,6 +16,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("""
<script>
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
CASE WHEN p.plan_status = 'COMPLETED' THEN COALESCE(p.confirmed_at, p.updated_at) END AS completed_loading_at,
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
THEN TRUE ELSE FALSE END AS exception_active,
@@ -77,6 +78,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("""
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
CASE WHEN p.plan_status = 'COMPLETED' THEN COALESCE(p.confirmed_at, p.updated_at) END AS completed_loading_at,
CASE WHEN (SELECT eh.action FROM fl_container_exception_history eh
WHERE eh.container_booking_id=b.id ORDER BY eh.occurred_at DESC,eh.id DESC LIMIT 1)='MARK'
THEN TRUE ELSE FALSE END AS exception_active,
@@ -3,6 +3,13 @@ package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {}
public interface ContainerTransportHistoryMapper extends BaseMapper<ContainerTransportHistoryEntity> {
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{bookingId} "
+ "AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1")
ContainerTransportHistoryEntity selectOperation(@Param("bookingId") long bookingId,
@Param("operatorId") long operatorId, @Param("operationId") String operationId);
}
@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */
@@ -15,6 +16,9 @@ public interface LoadingCompletedEventMapper {
@Select("""
SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.customer_id AS booking_customer_id, b.customer_name,
b.estimated_port_arrival_at, b.actual_port_arrival_at,
b.estimated_warehouse_arrival_at,
i.account_id, i.inbound_id AS source_inbound_id,
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
o.order_id AS source_order_id, o.order_no AS source_order_no,
@@ -49,7 +53,8 @@ public interface LoadingCompletedEventMapper {
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
@Select("""
SELECT i.account_id, i.inbound_id AS source_inbound_id,
a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count,
a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no,
a.picked_package_count AS allocated_package_count,
i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.package_count, i.product_quantity,
label.box_code
@@ -79,7 +84,10 @@ public interface LoadingCompletedEventMapper {
class Row {
private Long loadingPlanId; private String planNo; private Long containerBookingId;
private Integer aggregateVersion; private String containerNo; private String cabinetNo;
private Long warehouseId; private String warehouseName; private Long accountId;
private Long warehouseId; private String warehouseName; private Long bookingCustomerId;
private String customerName; private Long accountId;
private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt;
private LocalDateTime estimatedWarehouseArrivalAt;
private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId;
private Long loadingPlanItemId;
private String sourceOrderNo; private Long rootOrderId; private String inboundCode;
@@ -95,7 +103,8 @@ public interface LoadingCompletedEventMapper {
@Getter @Setter
class PalletRow {
private Long accountId; private Long sourceInboundId; private String palletCode;
private Long accountId; private Long sourceInboundId; private Long palletId;
private Long reservationId; private String palletCode;
private Integer allocationNo; private Integer allocatedPackageCount;
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
private Integer packageCount; private Integer productQuantity; private String boxCode;
@@ -0,0 +1,109 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface LoadingCompletionCommandMapper extends BaseMapper<LoadingCompletionCommandEntity> {
@Insert("""
INSERT IGNORE INTO fl_loading_completion_command
(id,pick_task_id,operation_id,request_hash,command_status,expected_task_version,remark,
operator_id,operator_name,attempt_count,next_retry_at)
VALUES
(#{id},#{pickTaskId},#{operationId},#{requestHash},'PROCESSING',#{expectedTaskVersion},#{remark},
#{operatorId},#{operatorName},0,CURRENT_TIMESTAMP(3))
""")
int insertCommand(LoadingCompletionCommandEntity command);
@Select("SELECT * FROM fl_loading_completion_command WHERE operation_id=#{operationId}")
LoadingCompletionCommandEntity findByOperation(@Param("operationId") String operationId);
@Select("""
SELECT * FROM fl_loading_completion_command
WHERE pick_task_id=#{taskId} AND command_status IN ('PROCESSING','RETRYABLE','FAILED')
ORDER BY created_at DESC LIMIT 1
""")
LoadingCompletionCommandEntity findActiveByTask(@Param("taskId") long taskId);
@Select("SELECT * FROM fl_loading_completion_command WHERE pick_task_id=#{taskId} ORDER BY created_at DESC LIMIT 1")
LoadingCompletionCommandEntity findLatestByTask(@Param("taskId") long taskId);
@Select("""
SELECT * FROM fl_loading_completion_command
WHERE command_status IN ('PROCESSING','RETRYABLE') AND next_retry_at<=CURRENT_TIMESTAMP(3)
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
ORDER BY next_retry_at,id LIMIT #{limit}
""")
List<LoadingCompletionCommandEntity> findDue(@Param("limit") int limit);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='PROCESSING',lease_owner=#{worker},lease_until=#{leaseUntil},
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status IN ('PROCESSING','RETRYABLE')
AND next_retry_at<=CURRENT_TIMESTAMP(3)
AND (lease_until IS NULL OR lease_until<CURRENT_TIMESTAMP(3))
""")
int claim(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_loading_completion_command SET lease_until=#{leaseUntil},updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int renew(@Param("id") long id, @Param("worker") String worker,
@Param("leaseUntil") LocalDateTime leaseUntil);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL,
failure_request_id=NULL,failure_trace_id=NULL
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int complete(@Param("id") long id, @Param("worker") String worker);
@Update("""
UPDATE fl_loading_completion_command
SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code},
failure_request_id=#{requestId},failure_trace_id=#{traceId}
WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker}
""")
int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status,
@Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage,
@Param("code") String code, @Param("requestId") String requestId, @Param("traceId") String traceId);
@Update("""
UPDATE fl_loading_completion_command
SET command_status='RETRYABLE',next_retry_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL,lease_until=NULL
WHERE id=#{id} AND command_status='FAILED'
""")
int resume(@Param("id") long id);
@Select("""
SELECT checkpoint_stage FROM fl_loading_completion_checkpoint
WHERE command_id=#{commandId} AND tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
String checkpoint(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId);
@Insert("""
INSERT INTO fl_loading_completion_checkpoint
(command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage)
VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage})
ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3)
""")
int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("sequence") int sequence,
@Param("stage") String stage);
}
@@ -232,6 +232,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
/**
* 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
* 异步完成期间任务会在 COMPLETING/COMPLETION_FAILED 间切换,不能把任务状态当成明细未完成事实。
*/
@Select("""
SELECT COUNT(*)
@@ -244,7 +245,6 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = plan_item.package_count)
@@ -19,6 +19,29 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
+ "AND task_status='LOADING' AND version=#{expectedVersion}")
int touch(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1
WHERE id=#{taskId} AND task_status='LOADING' AND version=#{expectedVersion}
""")
int acceptCompletion(@Param("taskId") long taskId, @Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_loading_pick_task
SET task_status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
completed_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{taskId} AND task_status='COMPLETING'
""")
int completeAccepted(@Param("taskId") long taskId, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETION_FAILED',version=version+1 "
+ "WHERE id=#{taskId} AND task_status='COMPLETING'")
int failCompletion(@Param("taskId") long taskId);
@Update("UPDATE fl_loading_pick_task SET task_status='COMPLETING',version=version+1 "
+ "WHERE id=#{taskId} AND task_status='COMPLETION_FAILED'")
int resumeCompletion(@Param("taskId") long taskId);
@Select("""
SELECT DISTINCT task.*
FROM fl_loading_pick_task task
@@ -60,6 +83,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
booking.merged_into_container_booking_id AS mergedIntoContainerBookingId,
booking.warehouse_name AS warehouseName, task.task_status AS taskStatus,
CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'
WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'
WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'
WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'
WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0
THEN 'AWAITING_REVIEW'
@@ -72,7 +97,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE'
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
@@ -37,6 +37,11 @@ public class PlatformOceanBookingController {
@RequestParam long originWarehouseId) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId));
}
@GetMapping("/booking-agents") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
public ApiResponse<List<BookingAgentOption>> bookingAgents(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String carrierCode) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAgents(carrierCode));
}
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
@@ -100,4 +100,5 @@ public final class OceanBookingDtos {
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
String priceModel, List<String> pricingTaxModes) {}
public record BookingAgentOption(Long supplierId, String name, List<String> carrierCodes) {}
}
@@ -6,6 +6,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehousePartnerClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
@@ -23,6 +25,7 @@ import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import feign.FeignException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -44,6 +47,7 @@ public class OceanBookingService {
@Resource private ObjectMapper objectMapper;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private WarehousePartnerClient partners;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -56,6 +60,14 @@ public class OceanBookingService {
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
}
public List<BookingAgentOption> bookingAgents(String carrierCode) {
String normalized = normalizeCarrierCode(carrierCode);
return suppliers.bookingCandidates(normalized).stream()
.filter(value -> "ACTIVE".equals(value.status()) && "BOOKING".equals(value.category())
&& value.carrierCodes() != null && value.carrierCodes().contains(normalized))
.map(value -> new BookingAgentOption(value.supplierId(), value.name(), value.carrierCodes())).toList();
}
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
@@ -138,6 +150,7 @@ public class OceanBookingService {
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id));
validateMasterData(before);
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
@@ -238,27 +251,27 @@ public class OceanBookingService {
return value.trim();
}
private record ResolvedDraft(String warehouseName, String channelName, String bookingAgentName) {}
private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName,
String carrierCode, String bookingAgentName) {}
private ResolvedDraft resolveDraft(DraftData d) {
String warehouseName = null; String channelName = null; String bookingAgentName = trim(d.bookingAgentName());
String warehouseName = null; String channelName = null;
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
warehouseName = warehouse.warehouseName();
channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName();
}
if (d.bookingAgentId() != null) {
var snapshot = suppliers.booking(d.bookingAgentId(), d.bookingAgentName());
bookingAgentName = snapshot == null ? null : snapshot.name();
} else if (StringUtils.hasText(d.bookingAgentName())) throw badRequest("订舱代理名称不能脱离供应商ID提交");
return new ResolvedDraft(warehouseName, channelName, bookingAgentName);
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(),
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name());
}
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase());
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(resolved.shippingCompanyName());
v.setCarrierCode(resolved.carrierCode());
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName());
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
@@ -268,6 +281,58 @@ public class OceanBookingService {
v.setRemark(trim(d.remark()));
}
private void validateMasterData(OceanBookingEntity booking) {
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode());
}
private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) {
if (id == null) {
if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交");
return null;
}
try {
var response = partners.active(internalToken, 0L, id);
var value = response == null ? null : response.data();
if (response == null || !response.successful() || value == null || value.id() != id
|| value.tenantId() != 0L || !"ACTIVE".equals(value.status())
|| value.types() == null || !value.types().contains("CARRIER")
|| !StringUtils.hasText(value.carrierCode())) throw badRequest("所选船公司不是启用中的CARRIER往来单位");
String code = normalizeCarrierCode(value.carrierCode());
if (StringUtils.hasText(suppliedCode) && !code.equals(normalizeCarrierCode(suppliedCode))) throw badRequest("船司标准编码与主数据快照不一致");
if (StringUtils.hasText(suppliedName) && !value.partnerName().equals(suppliedName.trim())) throw badRequest("船公司名称与主数据快照不一致");
return value;
} catch (FeignException exception) {
throw new BizException(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "船公司主数据服务暂不可用");
}
}
private WarehouseSupplierClient.Snapshot resolveBookingAgent(Long id, String name, String carrierCode) {
if (id == null) {
if (StringUtils.hasText(name)) throw badRequest("订舱代理名称不能脱离供应商ID提交");
return null;
}
WarehouseSupplierClient.Snapshot snapshot;
try {
snapshot = suppliers.booking(id, name);
} catch (BizException exception) {
if (exception.getResultCode() == ResultCode.SERVICE_UNAVAILABLE) throw exception;
throw badRequest("所选订舱代理未关联当前船公司");
}
if (!StringUtils.hasText(carrierCode) || snapshot.carrierCodes() == null
|| !snapshot.carrierCodes().contains(normalizeCarrierCode(carrierCode))) {
throw badRequest("所选订舱代理未关联当前船公司");
}
return snapshot;
}
private static String normalizeCarrierCode(String value) {
if (!StringUtils.hasText(value)) throw badRequest("carrierCode不能为空");
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
if (!CARRIER_CODES.contains(normalized)) throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER");
return normalized;
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
@@ -13,9 +13,12 @@ import org.springframework.stereotype.Repository;
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) {
return PageResult.from(mapper.selectBookingPage(new Page<>(query.page(), query.size()), query));
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
new Page<>(query.page(), query.size()), query);
result.getRecords().forEach(this::resolveLoadingTime);
return PageResult.from(result);
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean containerNoExists(String no, Long excludedId) {
@@ -56,4 +59,12 @@ public class ContainerRepository {
return mapper.syncReleasedTransport(oceanBookingId, billOfLadingNo, vesselName, voyageNo,
estimatedSailingAt, operatorId, operatorName);
}
private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) {
if (value != null && value.getLoadingAt() == null
&& "COMPLETED".equals(value.getPlanStatus())) {
value.setLoadingAt(value.getCompletedLoadingAt());
}
return value;
}
}
@@ -244,7 +244,8 @@ public class FbaPricingRepository {
public int markInboundConfirmed(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
.in(InboundPricingEntity::getProcessStatus, List.of("FEE_CONFIRMING", "WAIT_SPLIT"))
.in(InboundPricingEntity::getProcessStatus,
List.of("FEE_CONFIRMING", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"))
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class LoadingCompletionCommandRepository {
@Resource private LoadingCompletionCommandMapper mapper;
public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); }
public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); }
public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); }
public LoadingCompletionCommandEntity latest(long taskId) { return mapper.findLatestByTask(taskId); }
public List<LoadingCompletionCommandEntity> due(int limit) { return mapper.findDue(limit); }
public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; }
public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; }
public boolean complete(long id, String worker) { return mapper.complete(id, worker) == 1; }
public boolean fail(long id, String worker, String status, LocalDateTime nextRetryAt, String stage,
String code, String requestId, String traceId) {
return mapper.fail(id, worker, status, nextRetryAt, stage, code, requestId, traceId) == 1;
}
public boolean resume(long id) { return mapper.resume(id) == 1; }
public String checkpoint(long commandId, long tenantId, long inboundId) {
return mapper.checkpoint(commandId, tenantId, inboundId);
}
public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) {
mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage);
}
}
@@ -188,7 +188,7 @@ public class LoadingListRepository {
private List<History> histories(long planId) {
return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(),
row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getOperationId(), row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(),
row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(),
row.getOccurredAt())).toList();
}
@@ -57,6 +57,18 @@ public class LoadingPickRepository {
return tasks.touch(taskId, expectedVersion);
}
public int acceptCompletion(long taskId, int expectedVersion) {
return tasks.acceptCompletion(taskId, expectedVersion);
}
public int completeAccepted(long taskId, long operatorId, String operatorName) {
return tasks.completeAccepted(taskId, operatorId, operatorName);
}
public int failCompletion(long taskId) { return tasks.failCompletion(taskId); }
public int resumeCompletion(long taskId) { return tasks.resumeCompletion(taskId); }
public LoadingPickTaskEntity taskByPlan(long planId) {
return tasks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getLoadingPlanId, planId));
@@ -36,6 +36,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
@Service
public class ContainerService {
@@ -151,31 +155,87 @@ public class ContainerService {
@Transactional
public ContainerBookingEntity updateTransport(long id, long operatorId, String operatorName,
TransportUpdateRequest request) {
String idempotencyKey, TransportUpdateRequest request) {
String operationId = transportOperationId(id, idempotencyKey);
String containerNo = trim(request.containerNo());
String voyageNo = trim(request.voyageNo());
String requestHash = sha256("TRANSPORT|" + id + '|' + request.version() + '|'
+ Objects.toString(containerNo, "") + '|' + Objects.toString(voyageNo, "") + '|'
+ request.reason().trim());
ContainerTransportHistoryEntity replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return required(id);
}
ContainerBookingEntity current = required(id);
requireVersion(current, request.version());
if (status(current) == SmartLoadingStatus.COMPLETED) throw conflict("装柜完成后货柜全部只读,不能修改");
String containerNo = trim(request.containerNo());
replay = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency-Key已用于不同运输字段请求");
}
return current;
}
if (Objects.equals(current.getContainerNo(), containerNo)
&& Objects.equals(current.getVoyageNo(), voyageNo)) {
return current;
}
LoadingPlanEntity plan = smartLoadingRepository.planByBooking(id);
if (containerNo == null && plan != null && loadingPickRepository.taskByPlan(plan.getId()) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.updateTransport(id, containerNo, trim(request.voyageNo()), request.version(),
operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
if (repository.updateTransport(id, containerNo, voyageNo, request.version(),
operatorId, operatorName) != 1) {
ContainerTransportHistoryEntity concurrent = transportHistories == null ? null
: transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent != null && requestHash.equals(concurrent.getRequestHash())) {
return required(id);
}
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
} catch (DuplicateKeyException ex) { throw conflict("集装箱号已存在"); }
ContainerTransportHistoryEntity history = new ContainerTransportHistoryEntity();
history.setContainerBookingId(id); history.setBeforeContainerNo(current.getContainerNo());
history.setAfterContainerNo(containerNo); history.setBeforeVoyageNo(current.getVoyageNo());
history.setAfterVoyageNo(trim(request.voyageNo())); history.setReason(request.reason().trim());
history.setAfterVoyageNo(voyageNo); history.setOperationId(operationId);
history.setRequestHash(requestHash); history.setReason(request.reason().trim());
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
if (transportHistories != null) transportHistories.insert(history);
try {
if (transportHistories != null) transportHistories.insert(history);
} catch (DuplicateKeyException exception) {
ContainerTransportHistoryEntity concurrent = transportHistories.selectOperation(id, operatorId, operationId);
if (concurrent == null || !requestHash.equals(concurrent.getRequestHash())) {
throw conflict("Idempotency-Key已被并发用于不同运输字段请求");
}
}
ContainerBookingEntity updated = required(id);
loadingPickService.beginIfReady(updated.getId(), operatorId, operatorName);
return updated;
}
private static String transportOperationId(long id, String value) {
String key = trim(value);
if (key == null) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空");
if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key长度不能超过128");
return sha256("CONTAINER_TRANSPORT|" + id + '|' + key);
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256不可用", exception);
}
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
@@ -4,6 +4,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
@@ -73,14 +74,16 @@ public class InboundMeasurementService {
return toDetail(inbound, measurement, items, weights, review, declaration);
}
public List<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId) {
public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
long tenantId, long inboundId, long page, long size, String keyword) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
return loadPalletSnapshots(inbound).stream()
PageResult<WarehousePalletLocationClient.PalletSnapshot> source =
loadPalletSnapshotPage(inbound, page, size, keyword);
return new PageResult<>(source.records().stream()
.map(snapshot -> new InboundMeasurementResponses.PalletSnapshot(
snapshot.palletId(), snapshot.palletCode(), snapshot.palletType(),
snapshot.capacityCbm(), snapshot.tareWeightKg()))
.toList();
.toList(), source.page(), source.size(), source.total(), source.pages());
}
@Transactional
@@ -175,11 +178,8 @@ public class InboundMeasurementService {
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
}
WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshots(inbound).stream()
.filter(snapshot -> snapshot.palletId() == request.palletId())
.findFirst()
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
"所选托盘不属于当前入库仓库或已被删除"));
WarehousePalletLocationClient.PalletSnapshot pallet = loadPalletSnapshot(
inbound, request.palletId());
validatePalletSnapshot(pallet);
String unit = rawOcrUnit(request.ocrWeightUnit());
InboundWeightResultEntity weight = new InboundWeightResultEntity();
@@ -527,23 +527,43 @@ public class InboundMeasurementService {
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
}
private List<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshots(
InboundOrderEntity inbound) {
private PageResult<WarehousePalletLocationClient.PalletSnapshot> loadPalletSnapshotPage(
InboundOrderEntity inbound, long page, long size, String keyword) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<List<WarehousePalletLocationClient.PalletSnapshot>> response =
warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId());
ApiResponse<PageResult<WarehousePalletLocationClient.PalletSnapshot>> response =
warehousePallets.palletSnapshots(internalToken, inbound.getWarehouseId(), page, size, keyword);
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "托盘快照查询失败" : response.message();
? "托盘候选查询失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘快照");
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法查询托盘候选");
}
}
private WarehousePalletLocationClient.PalletSnapshot loadPalletSnapshot(
InboundOrderEntity inbound, long palletId) {
if (inbound.getWarehouseId() == null || inbound.getWarehouseId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单未绑定有效仓库,无法选择称重托盘");
}
try {
ApiResponse<WarehousePalletLocationClient.PalletSnapshot> response = warehousePallets.palletSnapshot(
internalToken, palletId, inbound.getWarehouseId());
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || !StringUtils.hasText(response.message())
? "所选托盘当前不可用于该入库仓库" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库服务暂不可用,无法校验托盘资格");
}
}
@@ -37,7 +37,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService {
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
validateCustomerSnapshots(rows);
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
@@ -67,7 +68,8 @@ public class LoadingCompletedOutboxService {
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()),
iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
@@ -102,12 +104,38 @@ public class LoadingCompletedOutboxService {
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows),
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets,
destinationType(sorted));
}
/** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */
private static Integer actualPalletCount(List<PalletRow> palletRows) {
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
.filter(Objects::nonNull).filter(value -> value > 0).collect(java.util.stream.Collectors.toSet());
return palletIds.isEmpty() ? null : palletIds.size();
}
private void validateCustomerSnapshots(List<Row> rows) {
for (Row row : rows) {
if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) {
throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件");
}
if (row.getCustomerName() == null || row.getCustomerName().isBlank()) {
throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件");
}
}
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> {
Set<String> names = values.stream().map(Row::getCustomerName).map(String::trim)
.collect(java.util.stream.Collectors.toSet());
if (names.size() != 1) {
throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照");
}
});
}
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
@@ -256,6 +284,7 @@ public class LoadingCompletedOutboxService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
private static String iso(java.time.LocalDateTime value) { return value == null ? null : value.toString(); }
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
/** Lease-based recovery worker for PDA completion commands. */
@Service
public class LoadingCompletionWorker {
private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class);
private static final int MAX_ATTEMPTS = 5;
private final String workerId = "pda-completion-" + UUID.randomUUID();
@Resource private LoadingCompletionCommandRepository commands;
@Resource private LoadingPickRepository picks;
@Resource private LoadingPickService loadingPicks;
@Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}")
public void dispatch() {
for (LoadingCompletionCommandEntity candidate : commands.due(10)) {
if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) {
continue;
}
try {
loadingPicks.processCompletion(candidate, workerId);
} catch (RuntimeException exception) {
handleFailure(candidate, exception);
}
}
}
private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) {
Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException
&& exception.getCause() != null ? exception.getCause() : exception;
String stage = exception instanceof LoadingPickService.CompletionProcessingException staged
? staged.stage() : "UNEXPECTED";
String code = cause instanceof BizException business
? business.getResultCode().code() : "S0500";
String requestId = cause instanceof PdaPickSettlementService.SettlementFailure remote
? remote.requestId() : null;
String traceId = cause instanceof PdaPickSettlementService.SettlementFailure remote
? remote.traceId() : null;
int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1;
boolean terminal = attempt >= MAX_ATTEMPTS;
LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4)));
boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt,
stage, code, requestId, traceId);
if (terminal && recorded) {
picks.failCompletion(command.getPickTaskId());
}
log.warn("pda_completion_worker_failure commandId={} taskId={} operationId={} stage={} code={} "
+ "requestId={} traceId={} attempt={} terminal={}", command.getId(), command.getPickTaskId(),
command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause);
}
}
@@ -496,6 +496,10 @@ public class LoadingListService {
}
requireLoadingMutable(before);
if (java.util.Objects.equals(before.getContainerNo(), containerNo)
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerNo == null && pickRepository.taskByPlan(planId) != null) {
throw conflict("PDA拣货任务已创建,不能清空真实集装箱号");
}
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
@@ -39,6 +41,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource;
@@ -66,6 +69,7 @@ import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
/** 装柜中 PDA 拣货任务及明细同步。 */
@Service
@@ -76,6 +80,8 @@ public class LoadingPickService {
@Resource
private LoadingPickRepository repository;
@Resource
private LoadingCompletionCommandRepository completionCommands;
@Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private OceanBookingRepository oceanBookingRepository;
@@ -440,9 +446,31 @@ public class LoadingPickService {
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
if (replay != null) return replay;
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
LoadingCompletionCommandEntity replay = completionCommands.operation(operationId);
if (replay != null) {
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
if ("FAILED".equals(replay.getCommandStatus())) {
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试");
}
}
return view(requiredTask(taskId), null);
}
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
LoadingCompletionCommandEntity concurrentReplay = completionCommands.operation(operationId);
if (concurrentReplay != null) {
requireSameCompletionCommand(concurrentReplay, taskId, requestHash, operatorId);
return view(task, null);
}
LoadingCompletionCommandEntity active = completionCommands.active(taskId);
if (active != null) {
throw conflict("已有其他装柜完成命令正在处理");
}
if (!"LOADING".equals(task.getTaskStatus())) {
throw conflict("装柜任务正在完成或已经完成");
}
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
@@ -454,40 +482,122 @@ public class LoadingPickService {
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.toList();
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0);
List<LoadingPickItemMapper.InboundOwner> owners = discoveredOwners.stream()
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(IdWorker.getId());
command.setPickTaskId(taskId);
command.setOperationId(operationId);
command.setRequestHash(requestHash);
command.setExpectedTaskVersion(request.expectedTaskVersion());
command.setRemark(remark);
command.setOperatorId(operatorId);
command.setOperatorName(trim(operatorName));
if (completionCommands.insert(command) != 1) {
LoadingCompletionCommandEntity concurrent = completionCommands.operation(operationId);
requireSameCompletionCommand(concurrent, taskId, requestHash, operatorId);
return view(requiredTask(taskId), null);
}
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
return view(requiredTask(taskId), null);
}
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
LoadingPickTaskEntity task = requiredTask(taskId);
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList();
for (LoadingPickItemMapper.InboundOwner owner : owners) {
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
completionStage(taskId, "完成前业务门禁", () ->
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId()));
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
long evidenceInboundId = owners.get(0).inboundId();
for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "READY")) {
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId(), evidenceInboundId));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "READY");
}
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
}
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(), evidenceInboundId, command.getOperatorId(),
command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(),
command.getRemark()));
requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
"INBOUND_COMPLETED");
}
}
completionStage(taskId, "拆单与库位结算", () ->
settlementService.settle(taskId, operatorId, operatorName));
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
}
if (repository.approve(taskId, operatorId, operatorName, remark) != 1) {
throw conflict("PDA任务已经复核");
if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(),
command.getOperatorName(), command.getRemark()) != 1) {
throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核"));
}
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
completionStage(taskId, "装柜计划完成", () ->
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
repository.syncInboundProjections(taskId, operatorId, operatorName);
for (LoadingPickItemMapper.InboundOwner owner : owners) {
completionStage(taskId, "入库单状态完成", () ->
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId(), operatorId, operatorName,
"PDA-" + operationId + '-' + owner.inboundId(), remark));
if (repository.operation(command.getOperationId()) == null) {
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
command.getOperatorId(), command.getOperatorName());
}
completionStage(taskId, "完成事件发布", () ->
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
command.getOperatorId(), command.getOperatorName()));
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
requireCompletionLease(command.getId(), worker);
LoadingPickTaskEntity current = requiredTask(taskId);
if (!"COMPLETED".equals(current.getTaskStatus())
&& repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) {
throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化"));
}
if (!completionCommands.complete(command.getId(), worker)) {
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
}
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
private void requireCompletionLease(long commandId, String worker) {
if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) {
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
}
}
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
String requestHash, long operatorId) {
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|| !Long.valueOf(operatorId).equals(command.getOperatorId())
|| !requestHash.equals(command.getRequestHash())) {
throw conflict("operationId已用于不同装柜完成命令");
}
}
private static boolean checkpointAtLeast(String checkpoint, String target) {
List<String> stages = List.of("READY", "SETTLED", "INBOUND_COMPLETED");
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
}
private static void runCompletionStage(String stage, Runnable action) {
try {
action.run();
} catch (RuntimeException exception) {
throw new CompletionProcessingException(stage, exception);
}
}
public static final class CompletionProcessingException extends RuntimeException {
private final String stage;
CompletionProcessingException(String stage, RuntimeException cause) { super(cause); this.stage = stage; }
public String stage() { return stage; }
}
private void completionStage(long taskId, String stage, Runnable action) {
@@ -692,6 +802,13 @@ public class LoadingPickService {
}
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
var booking = repository.activeBookingByTask(task.getId());
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
if (warehouseId != null && !Objects.equals(warehouseId, booking.getWarehouseId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或不属于当前仓库");
}
List<LoadingPickItemEntity> source = warehouseId == null
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
@@ -714,24 +831,28 @@ public class LoadingPickService {
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
: "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING"
: "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED"
: assigned == null ? "UNASSIGNED"
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
var booking = repository.activeBookingByTask(task.getId());
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
}
var owners = repository.inboundOwners(task.getId());
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
var owner = detailInboundId == null ? null : owners.stream()
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
? null : repository.inbound(owner.tenantId(), owner.inboundId());
LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus())
? null : completionCommands.latest(task.getId());
CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(),
command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(),
command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(),
command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt());
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
inbound == null ? null : inbound.version(), task.getCabinetNo(),
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
workflowStatus, task.getVersion(), assignment, review, items);
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
}
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
@@ -18,6 +18,8 @@ import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Comparator;
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
@Service
public class PdaPickSettlementService {
private static final Logger log = LoggerFactory.getLogger(PdaPickSettlementService.class);
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@@ -82,6 +85,35 @@ public class PdaPickSettlementService {
}
}
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
@Transactional
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
if (!picks.allTaskItemsResolved(taskId)) {
throw conflict("PDA任务仍有未处理明细");
}
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
Integer pickedPackageCount = pickedByInbound.get(inboundId);
if (pickedPackageCount == null) {
throw conflict("PDA任务不包含指定入库单");
}
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
for (Long planItemId : resolution.skippedPlanItemIds()) {
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
if (changed != 0 && changed != 1) {
throw conflict("跳单明细已被并发修改,请刷新后重试");
}
}
boolean newlySplit = settleInbound(inboundId, pickedPackageCount, taskItems, taskId, operatorId, operatorName);
if (newlySplit) {
for (PdaPickSettlementPlan.SplitLine line : resolution.partialByInbound()
.getOrDefault(inboundId, List.of())) {
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
"部分拣货装柜明细");
}
}
}
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
List<LoadingPickItemEntity> taskItems, long taskId,
long operatorId, String operatorName) {
@@ -379,7 +411,7 @@ public class PdaPickSettlementService {
private static List<Long> releasedReservationIds(List<InboundPalletAllocationEntity> allocations) {
return allocations.stream().filter(item -> remaining(item) == 0).map(PdaPickSettlementService::reservationId)
.sorted().toList();
.distinct().sorted().toList();
}
private static List<WarehousePalletLocationClient.SettlementTransferItem> remainingTransferItems(
@@ -422,18 +454,51 @@ public class PdaPickSettlementService {
operatorId, operatorName, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
releasedReservationIds, remainingItems));
} catch (FeignException exception) {
log.warn("PDA warehouse settlement transport failure taskId={} sourceInboundId={} operationId={} "
+ "httpStatus={} releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
exception.status(), releasedReservationIds, settlementTransferFacts(remainingItems));
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
}
if (response == null || !response.successful() || response.data() == null
|| response.data().released() == null || response.data().transferred() == null
|| !sameReleasedReservations(releasedReservationIds, response.data().released())
|| !sameTransferredResources(remainingItems, response.data().transferred())) {
log.warn("PDA warehouse settlement rejected taskId={} sourceInboundId={} operationId={} "
+ "responseCode={} requestId={} traceId={} expectedAction=RELEASE_OR_TRANSFER "
+ "releasedReservationIds={} remainingReservations={}",
taskId, parentInboundId, "PDA-SETTLE-" + taskId + '-' + parentInboundId,
response == null ? null : response.code(), response == null ? null : response.requestId(),
response == null ? null : response.traceId(), releasedReservationIds,
settlementTransferFacts(remainingItems));
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
throw new BizException(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message());
throw new SettlementFailure(code, response == null || response.message() == null
? "仓储服务原子结算PDA库位资源失败" : response.message(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
}
}
/** Carries only correlation facts needed by the durable command diagnosis record. */
public static final class SettlementFailure extends BizException {
private final String requestId;
private final String traceId;
SettlementFailure(ResultCode code, String message, String requestId, String traceId) {
super(code, message);
this.requestId = requestId;
this.traceId = traceId;
}
public String requestId() { return requestId; }
public String traceId() { return traceId; }
}
private static List<String> settlementTransferFacts(
List<WarehousePalletLocationClient.SettlementTransferItem> remainingItems) {
return remainingItems.stream().map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.sorted().toList();
}
private long existingSettlementTarget(long sourceInboundId, String idempotencyKey) {
try {
ApiResponse<Long> response = warehouse.settlementTarget(internalToken, sourceInboundId, idempotencyKey);
@@ -317,17 +317,27 @@ public class SmartLoadingService {
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
others.removeIf(x -> Objects.equals(x.getId(), itemId));
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
String before = json(adjustSnapshot(item));
LoadingPlanItemEntity desired = new LoadingPlanItemEntity();
desired.setPackageCount(request.packageCount());
desired.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount())));
desired.setPalletCount(estimatedPalletCount(candidate, request.packageCount()));
applyCargo(desired, cargo, request.packageCount());
if (sameAdjustSnapshot(item, desired)) {
return view(plan, booking);
}
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount());
item.setPackageCount(desired.getPackageCount());
item.setGrossWeightKg(desired.getGrossWeightKg());
item.setNetWeightKg(desired.getNetWeightKg());
item.setVolumeCbm(desired.getVolumeCbm());
item.setPalletCount(desired.getPalletCount()); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
before, json(values("requestHash", requestHash, "business", adjustSnapshot(item))),
operatorId, operatorName);
loadingPickService.sync(planId, item);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
@@ -349,6 +359,9 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Objects.equals(item.getWarehouseRemark(), remark)) {
return view(plan, requireBooking(plan.getContainerBookingId()));
}
String before = json(values("warehouseRemark", item.getWarehouseRemark(), "version", item.getVersion()));
try {
touch(plan, operatorId, operatorName);
@@ -480,6 +493,33 @@ public class SmartLoadingService {
}
}
private static boolean sameAdjustSnapshot(LoadingPlanItemEntity current, LoadingPlanItemEntity desired) {
return Objects.equals(current.getPackageCount(), desired.getPackageCount())
&& decimalEquals(current.getGrossWeightKg(), desired.getGrossWeightKg())
&& decimalEquals(current.getNetWeightKg(), desired.getNetWeightKg())
&& decimalEquals(current.getVolumeCbm(), desired.getVolumeCbm())
&& Objects.equals(current.getPalletCount(), desired.getPalletCount())
&& Objects.equals(current.getSkuCode(), desired.getSkuCode())
&& Objects.equals(current.getProductNameEn(), desired.getProductNameEn())
&& Objects.equals(current.getUnitsPerCarton(), desired.getUnitsPerCarton())
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity())
&& Objects.equals(current.getCustomerRemark(), desired.getCustomerRemark())
&& Objects.equals(current.getInternalRemark(), desired.getInternalRemark());
}
private static boolean decimalEquals(BigDecimal left, BigDecimal right) {
return left == null ? right == null : right != null && left.compareTo(right) == 0;
}
private static Map<String, Object> adjustSnapshot(LoadingPlanItemEntity item) {
return values("packageCount", item.getPackageCount(), "grossWeightKg", item.getGrossWeightKg(),
"netWeightKg", item.getNetWeightKg(), "volumeCbm", item.getVolumeCbm(),
"palletCount", item.getPalletCount(), "skuCode", item.getSkuCode(),
"productNameEn", item.getProductNameEn(), "unitsPerCarton", item.getUnitsPerCarton(),
"productQuantity", item.getProductQuantity(), "customerRemark", item.getCustomerRemark(),
"internalRemark", item.getInternalRemark(), "version", item.getVersion());
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
try {
ApiResponse<OrderCargoClient.CargoBinding> response = orderCargoClient.detail(internalToken,
@@ -8,6 +8,7 @@ import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Service
public class WarehouseSupplierSnapshotService {
@@ -18,6 +19,18 @@ public class WarehouseSupplierSnapshotService {
return resolve(supplierId, suppliedName, "BOOKING");
}
public List<WarehouseSupplierClient.Snapshot> bookingCandidates(String carrierCode) {
try {
var response = client.candidates(internalToken, "BOOKING", carrierCode);
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订舱代理候选服务暂不可用");
}
return response.data();
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订舱代理候选服务暂不可用");
}
}
public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) {
return resolve(supplierId, suppliedName, "TRUCKING");
}
@@ -21,6 +21,7 @@ import org.springframework.util.StringUtils;
import org.springframework.dao.DuplicateKeyException;
import java.util.List;
import java.util.Set;
/** 分柜、预装清单及其主状态推进;不校验资料完整度和客户确认。 */
@Service
@@ -73,8 +74,8 @@ public class SplitPreloadService {
/**
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
* confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns
* the active PDA task lock and will synchronise the restored loading item immediately.
* confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED
* lifecycles may be reused while the restored loading item is synchronised atomically.
*/
@Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
@@ -96,7 +97,8 @@ public class SplitPreloadService {
}
if (plan != null) {
if (!PreloadStatus.PENDING.equals(plan.getStatus())
&& !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) {
&& !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
.contains(plan.getStatus()))) {
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
}
return;
@@ -166,9 +166,12 @@ CREATE TABLE IF NOT EXISTS fl_container_transport_history (
after_container_no VARCHAR(64) NULL COMMENT '变更后真实集装箱号',
before_voyage_no VARCHAR(64) NULL COMMENT '变更前航次号',
after_voyage_no VARCHAR(64) NULL COMMENT '变更后航次号',
operation_id VARCHAR(64) NOT NULL COMMENT '运输字段操作幂等ID',
request_hash CHAR(64) NOT NULL COMMENT '运输字段请求哈希',
reason VARCHAR(500) NULL COMMENT '运输字段变更原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '变更操作人ID',
operator_name VARCHAR(64) NULL COMMENT '变更操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_container_transport_history_operation (container_booking_id,operator_id,operation_id),
KEY idx_container_transport_history (container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
@@ -564,7 +564,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
cabinet_no VARCHAR(64) NOT NULL COMMENT '稳定内部柜次号快照',
container_no VARCHAR(64) NULL COMMENT '废弃兼容字段;不得作为任务身份或随运输号变更重写',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING、COMPLETING、COMPLETION_FAILED、COMPLETED',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -577,9 +577,52 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
UNIQUE KEY uk_loading_pick_task_booking (container_booking_id),
KEY idx_loading_pick_container (container_no, task_status, started_at),
CONSTRAINT fk_loading_pick_task_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id),
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING', 'COMPLETED'))
CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜拣货任务';
CREATE TABLE IF NOT EXISTS fl_loading_completion_command (
id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
command_status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED',
expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本',
remark VARCHAR(500) NULL COMMENT '完成备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间',
lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者',
lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间',
failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段',
failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码',
failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID',
failure_trace_id VARCHAR(64) NULL COMMENT '下游链路ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_loading_completion_operation (operation_id),
KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until),
CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id),
CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令';
CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint (
command_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
owner_sequence INT UNSIGNED NOT NULL,
checkpoint_stage VARCHAR(24) NOT NULL COMMENT 'READY、SETTLED、INBOUND_COMPLETED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (command_id,tenant_id,inbound_id),
KEY idx_loading_completion_owner (command_id,owner_sequence),
CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id)
REFERENCES fl_loading_completion_command(id),
CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED'))
) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
@@ -819,7 +862,6 @@ BEGIN
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
@@ -1018,8 +1060,10 @@ BEGIN
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
END IF;
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法';
END IF;
END$$
DELIMITER ;
@@ -330,9 +330,10 @@ paths:
/api/platform/v1/containers/{id}/transport:
patch:
summary: 修改集装箱号和航次
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读
description: 需要 platform:firstleg:container:edit 权限;LOADING 仅开放此白名单,COMPLETED 全部只读;相同幂等键精确重放零新增历史,相同业务字段为无效变更且不递增版本、不写历史
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
@@ -755,11 +756,14 @@ paths:
get:
summary: 查询称重可选托盘快照
description: >-
需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 查询 Warehouse 公共托盘快照;
仅限定同仓且未删除,不校验托盘状态或占用资格,Admin 不需要 platform:pallet:list。
需要入库测量查看或称重编辑权限。Firstleg 按入库单 warehouseId 分页查询 Warehouse 当前称重托盘候选;
Warehouse 校验平台公共作用域、启用国内仓、同仓归属和当前未删除资格。托盘可复用,不按预占或占用过滤,Admin 不需要 platform:pallet:list。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 100, default: 20}}
- {name: keyword, in: query, required: false, description: 匹配托盘编码或类型, schema: {type: string, maxLength: 50}}
responses:
'200':
description: 成功或业务失败,按响应体 code 判断
@@ -770,7 +774,7 @@ paths:
properties:
code: {type: string}
message: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/WeightPalletSnapshot'}}
data: {$ref: '#/components/schemas/WeightPalletOptionPage'}
'403': {description: 无入库测量查看或称重编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:
post:
@@ -1294,6 +1298,18 @@ components:
palletType: {type: string}
capacityCbm: {type: number, format: double, minimum: 0.0001}
tareWeightKg: {type: number, format: double, minimum: 0, multipleOf: 0.001}
WeightPalletOptionPage:
type: object
required: [records, page, size, total, pages]
properties:
records:
type: array
maxItems: 100
items: {$ref: '#/components/schemas/WeightPalletSnapshot'}
page: {type: integer, format: int64, minimum: 1}
size: {type: integer, format: int64, minimum: 1, maximum: 100}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
CreateWeightResultRequest:
type: object
required: [fileId, palletId]
@@ -427,10 +427,11 @@ components:
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
OperationHistory:
type: object
required: [historyId, actionType, operatorId, occurredAt]
required: [historyId, operationId, actionType, operatorId, occurredAt]
properties:
historyId: {$ref: '#/components/schemas/LongId'}
itemId: {$ref: '#/components/schemas/NullableLongId'}
operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重}
actionType: {type: string, description: 操作类型}
fromStatus: {type: string, nullable: true, description: 操作前状态}
toStatus: {type: string, nullable: true, description: 操作后状态}
@@ -36,7 +36,13 @@ paths:
/api/platform/v1/ocean-bookings/link-candidates:
get:
summary: Page released booking requirement candidates for one container
description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command.
description: >-
Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates:
RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType.
Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity
counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one
bounded set query; booking detail calls are not used. An empty result is a normal successful page.
Final target-container mutability is revalidated by the link command.
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -62,6 +68,19 @@ paths:
content:
application/json:
schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}
/api/platform/v1/ocean-bookings/booking-agents:
get:
summary: List ACTIVE BOOKING agents associated with one carrier code
description: Requires platform:firstleg:ocean-booking:list. Filtering is enforced by Warehouse master data; clients must not infer associations.
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: carrierCode, in: query, required: true, schema: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}}
responses:
'200':
description: Server-filtered booking agent options
content:
application/json:
schema: {type: array, items: {$ref: '#/components/schemas/BookingAgentOption'}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -121,6 +140,13 @@ components:
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
BookingAgentOption:
type: object
required: [supplierId, name, carrierCodes]
properties:
supplierId: {type: integer, format: int64}
name: {type: string}
carrierCodes: {type: array, items: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}}
LinkCandidate:
type: object
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
@@ -106,13 +106,13 @@ paths:
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
post:
summary: PDA拣货全部处理后人工确认装柜完成
description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。
description: 短事务校验任务版本、全部明细和operationId请求摘要,持久化可靠完成命令并将任务置为COMPLETING后立即返回PROCESSING事实。后台worker按稳定入库单顺序以checkpoint恢复Order、Warehouse、入库状态、计划和事件;请求事务内不调用任何远程服务。同operationId重放立即返回相同PROCESSING或最终结果;不同operationId在处理期间返回A0409。
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/CompleteLoadingRequest'}}}
responses:
'200': {description: 完成任务;存在未处理明细或版本冲突时返回A0409}
'200': {description: 已可靠受理;taskStatus=COMPLETING且workflowStatus=PROCESSING,或幂等返回最终COMPLETED结果;冲突返回A0409}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/operations:
get:
summary: Read immutable task-level claim, assignment and review audit records
@@ -139,8 +139,9 @@ components:
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity}
recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan}
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history}
taskStatus: {type: string, enum: [LOADING, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]}
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]}
completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true}
version: {type: integer, minimum: 0}
assignment: {type: object, nullable: true}
review: {type: object, nullable: true}
@@ -181,8 +182,8 @@ components:
mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task}
warehouseId: {type: integer, format: int64}
warehouseName: {type: string, nullable: true}
taskStatus: {type: string, enum: [LOADING]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW}
taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED]}
workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED], description: Server projection aligned with task detail; completion acceptance is returned immediately as PROCESSING}
version: {type: integer, minimum: 0}
assignmentVersion: {type: integer, minimum: 0}
itemCount: {type: integer, minimum: 1}
@@ -236,6 +237,22 @@ components:
expectedTaskVersion: {type: integer, minimum: 0}
operationId: {type: string, maxLength: 128}
remark: {type: string, maxLength: 500}
CompletionCommand:
type: object
required: [id, operationId, status, expectedTaskVersion, attemptCount]
properties:
id: {type: integer, format: int64}
operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity}
status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]}
expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version}
remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark}
attemptCount: {type: integer, minimum: 0}
nextRetryAt: {type: string, format: date-time, nullable: true}
failureStage: {type: string, nullable: true}
failureCode: {type: string, nullable: true}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
CompleteLoadingRequest:
type: object
required: [expectedTaskVersion, operationId]
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PathVariable;
import java.lang.reflect.Method;
@@ -16,11 +17,26 @@ class WarehousePalletLocationClientContractTest {
assertThat(client.path()).isEqualTo("/internal/warehouse/v1");
Method method = WarehousePalletLocationClient.class.getDeclaredMethod(
"palletSnapshots", String.class, long.class);
"palletSnapshots", String.class, long.class, long.class, long.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/pallets/snapshots");
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).value())
.isEqualTo("warehouseId");
assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value())
.isEqualTo("page");
assertThat(method.getParameters()[3].getAnnotation(RequestParam.class).value())
.isEqualTo("size");
assertThat(method.getParameters()[4].getAnnotation(RequestParam.class).value())
.isEqualTo("keyword");
Method exact = WarehousePalletLocationClient.class.getDeclaredMethod(
"palletSnapshot", String.class, long.class, long.class);
assertThat(exact.getAnnotation(GetMapping.class).value())
.containsExactly("/pallets/{palletId}/snapshot");
assertThat(exact.getParameters()[1].getAnnotation(PathVariable.class).value())
.isEqualTo("palletId");
assertThat(exact.getParameters()[2].getAnnotation(RequestParam.class).value())
.isEqualTo("warehouseId");
var snapshot = new WarehousePalletLocationClient.PalletSnapshot(
101L, "PLT-101", "STANDARD", new java.math.BigDecimal("2.0000"),
@@ -180,7 +180,7 @@ class LoadingListControllerContractTest {
"itemId", "accountId", "inboundId", "inboundDetailId", "skuCode",
"destinationWarehouse", "productQuantity", "frozenAt");
assertThat(properties(schemas, "OperationHistory")).containsKeys(
"historyId", "itemId", "actionType", "fromStatus", "toStatus",
"historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus",
"beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt");
assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys(
"containerNo", "voyageNo", "expectedVersion", "operationReason");
@@ -39,7 +39,8 @@ class PdaConvergenceOpenApiContractTest {
.contains("sourceType", "recordStatus", "mergedIntoContainerBookingId")
.contains("Task detail is served only from the ACTIVE container booking")
.contains("Enforced by the server-side pending-task query")
.contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW")
.contains("completion acceptance is returned immediately as PROCESSING")
.contains("taskStatus=COMPLETING且workflowStatus=PROCESSING")
.contains("CompleteLoadingRequest");
String taskLookup = yaml.substring(
@@ -6,10 +6,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@@ -17,6 +20,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("平台入库测量接口权限契约")
class PlatformInboundMeasurementControllerContractTest {
@Test
void palletCandidatesArePaginatedAndBoundedInOpenApi() throws IOException {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:")
.contains("name: keyword")
.contains("maximum: 100")
.contains("maxItems: 100")
.contains("data: {$ref: '#/components/schemas/WeightPalletOptionPage'}");
}
@Test
void draftRequestOnlyAcceptsEditableGoodsNameAndMeasurementItems() {
assertThat(Arrays.stream(InboundMeasurementRequests.SaveDraftRequest.class.getRecordComponents())
@@ -41,7 +57,7 @@ class PlatformInboundMeasurementControllerContractTest {
assertAuthority("weightPalletOptions",
"hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, long.class);
AuthenticatedUser.class, long.class, long.class, long.class, long.class, String.class);
assertAuthority("saveDraft", "hasAuthority('platform:firstleg:inbound:measure:edit')",
AuthenticatedUser.class, long.class, long.class,
InboundMeasurementRequests.SaveDraftRequest.class);
@@ -34,7 +34,7 @@ class CustomsServiceTest {
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "token");
when(suppliers.customs(anyLong(), anyString()))
.thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE"));
.thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE", java.util.List.of()));
when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1);
}
@@ -190,6 +190,20 @@ class LoadingModelBridgeServiceTest {
.hasMessageContaining("尚未全部完成装柜");
}
@Test
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
when(repository.byPair(10, 20, 40)).thenReturn(null);
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now()));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(2);
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
service.bindIncrementalAllocation(40L, 10L, 20L, 7L, "admin", "assign-confirming");
verify(repository).insert(any());
verify(repository).confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin");
}
private static Link link(String preloadStatus, int preloadVersion, String loadingStatus,
int loadingVersion, int packages) {
return new Link(1L, 10L, 20L, 30L, preloadStatus, preloadVersion, 40L, 50L, "C1",
@@ -25,6 +25,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -195,6 +196,65 @@ class LoadingDeliveryServiceTest {
verify(repository).findInboundForUpdate(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository, never()).completeLoadedChild(10, 20, 7, 6);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeResumesSameProcessingClaimLeftByAnEarlierWorkerFailure() {
readyForPdaFinalizeFacts();
CompletionTarget settled = new CompletionTarget(20L, null, 20L,
"LOADING", 7, "LOADING", 7);
when(repository.completionTarget(10, 20)).thenReturn(settled);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 7));
when(repository.completeLoadedChild(10, 20, 7, 7)).thenReturn(1);
AtomicReference<String> hash = new AtomicReference<>();
when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"),
eq("pda-resume"), anyString())).thenAnswer(invocation -> {
hash.set(invocation.getArgument(5));
return false;
});
when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "pda-resume"))
.thenAnswer(invocation -> processingOperation(hash.get()));
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-resume", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).completeLoadedChild(10, 20, 7, 7);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-resume"), anyString());
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
void pdaFinalizeConvergesAnAlreadyLoadedInboundBeforeThePlanIsCompleted() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
when(repository.findInboundForUpdate(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADED", 8));
when(repository.listEvidence(10, 20)).thenReturn(List.of());
LoadingActionResponse result = service.completeAfterPdaSettlement(
10, 20, 7, "admin", "pda-already-loaded", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
verify(repository).completeOperation(eq(10L), eq(20L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-already-loaded"), anyString());
}
@Test
void pdaFinalizeEntryPointsAreTransactional() throws Exception {
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull();
}
@Test
@@ -213,6 +273,8 @@ class LoadingDeliveryServiceTest {
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).findInboundForUpdate(10, 21);
verify(repository).completeLoadedChild(10, 20, 7, 2);
verify(loadingModelBridge).requireStarted(10, 20);
verify(loadingModelBridge, never()).requireCompleted(10, 20);
}
@Test
@@ -350,6 +412,24 @@ class LoadingDeliveryServiceTest {
"PHOTO_STEP_1".equals(value.getEvidenceType()) && "ACTIVE".equals(value.getStatus())));
}
@Test
void replaysTheSameActiveEvidenceFileWithoutAppendingAnotherRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
LoadingEvidenceEntity existing = evidence("SEAL", 11L, "LOCK-001");
existing.setId(99L); existing.setInboundId(20L); existing.setTenantId(10L); existing.setStatus("ACTIVE");
when(repository.findEvidenceBySealNo(10, "LOCK-001")).thenReturn(existing);
when(repository.findActiveEvidenceByFile(10, 20, "SEAL", 11L)).thenReturn(existing);
when(repository.listEvidence(10, 20)).thenReturn(List.of(existing));
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "same-seal-photo",
new UpsertEvidenceRequest(0, "SEAL", 11L, null, "LOCK-001", null));
assertThat(result.evidences().evidences()).hasSize(1);
verify(repository, never()).createEvidence(any());
verify(files, never()).validate(anyString(), anyLong(), anyLong(), anyLong());
}
@Test
void voidsPhotoWithReasonAndKeepsTheRecord() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -418,11 +498,16 @@ class LoadingDeliveryServiceTest {
private void readyForPdaFinalize() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(true);
readyForPdaFinalizeFacts();
}
private void readyForPdaFinalizeFacts() {
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(5);
when(progress.getPickedCount()).thenReturn(5);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(1);
// completion worker runs before task finalization; accepted COMPLETING is not COMPLETED yet.
when(progress.getCompletedTaskCount()).thenReturn(0);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress);
when(repository.listEvidence(10, 20)).thenReturn(List.of(
@@ -444,4 +529,11 @@ class LoadingDeliveryServiceTest {
value.setResultJson(resultJson);
return value;
}
private static LoadingOperationEntity processingOperation(String requestHash) {
LoadingOperationEntity value = new LoadingOperationEntity();
value.setRequestHash(requestHash);
value.setStatus("PROCESSING");
return value;
}
}
@@ -21,6 +21,7 @@ class PlatformOceanBookingControllerContractTest {
Map<String, String> permissions = Map.ofEntries(
Map.entry("page", "platform:firstleg:ocean-booking:list"),
Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"),
Map.entry("bookingAgents", "platform:firstleg:ocean-booking:list"),
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
@@ -31,7 +32,7 @@ class PlatformOceanBookingControllerContractTest {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") && !name.equals("bookingAgents")
&& !name.equals("linkCandidates")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
@@ -94,6 +95,10 @@ class PlatformOceanBookingControllerContractTest {
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]")
.contains("Required before RELEASE")
.contains("OTHER means manual carrier-node tracking");
assertThat(yaml)
.contains("/api/platform/v1/ocean-bookings/booking-agents:")
.contains("Filtering is enforced by Warehouse master data")
.contains("BookingAgentOption:");
}
private static Method method(String name) {
@@ -37,6 +37,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryUseCase priceBooks;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -116,6 +117,7 @@ class OceanBookingServiceTest {
@Test
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
stubMasterData();
OceanBookingEntity draft = completeBooking("DRAFT", 3);
OceanBookingEntity released = completeBooking("RELEASED", 4);
when(repository.find(1L)).thenReturn(draft, released);
@@ -139,6 +141,51 @@ class OceanBookingServiceTest {
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
@Test
void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() {
stubMasterData();
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER",
"SML", List.of("CARRIER"), "ACTIVE")));
assertThatThrownBy(() -> service.create(9L, "admin", "carrier-mismatch",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessage("船司标准编码与主数据快照不一致");
stubMasterData();
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
assertThatThrownBy(() -> service.create(9L, "admin", "agent-mismatch",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
void bookingAgentCandidatesAreFilteredByCarrierCode() {
when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of(
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON")),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"))));
assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name)
.containsExactly("匹配代理");
}
@Test
void releaseRevalidatesCurrentCarrierAndBookingAgentAssociation() {
stubMasterData();
OceanBookingEntity draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-agent-mismatch",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void releasedAggregateRejectsEditDeleteAndRepeatRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
@@ -255,7 +302,10 @@ class OceanBookingServiceTest {
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE"));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER",
"MATSON", List.of("CARRIER"), "ACTIVE")));
}
private static DraftData partialDraft(String remark) {
@@ -26,6 +26,8 @@ class ContainerBookingMapperContractTest {
.contains("fl_loading_plan")
.contains("p.id AS plan_id")
.contains("p.plan_no AS plan_no")
.contains("completed_loading_at")
.contains("p.confirmed_at")
.contains("fl_container_exception_history")
.contains("destination_clearance_document_status")
.contains("delivery_order_document_status")
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerRepositoryLoadingTimeTest {
@Mock private ContainerBookingMapper mapper;
@InjectMocks private ContainerRepository repository;
@Test
void projectsPlanConfirmationTimeWhenCompletedBookingHasNoSchedule() {
LocalDateTime completedAt = LocalDateTime.of(2026, 8, 27, 10, 15);
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setPlanStatus("COMPLETED");
booking.setCompletedLoadingAt(completedAt);
when(mapper.selectBooking(7L)).thenReturn(booking);
ContainerBookingEntity result = repository.find(7L);
assertThat(result.getLoadingAt()).isEqualTo(completedAt);
}
@Test
void preservesExistingScheduleForUncompletedBooking() {
LocalDateTime plannedAt = LocalDateTime.of(2026, 8, 28, 9, 0);
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setPlanStatus("LOADING");
booking.setLoadingAt(plannedAt);
booking.setCompletedLoadingAt(LocalDateTime.of(2026, 8, 27, 10, 15));
when(mapper.selectBooking(8L)).thenReturn(booking);
ContainerBookingEntity result = repository.find(8L);
assertThat(result.getLoadingAt()).isEqualTo(plannedAt);
}
}
@@ -28,6 +28,9 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.Collection;
import java.util.List;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@@ -184,6 +187,20 @@ class FbaPricingRepositoryRuleListTest {
.contains(10001L, 88L, "FEE_CONFIRMING", "CONFIRMED", "SENT", "WAIT_PALLET");
}
@Test
void feeConfirmationAllowsPostLoadingInboundStatuses() {
String source;
try {
source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java"),
StandardCharsets.UTF_8);
} catch (java.io.IOException exception) {
throw new AssertionError(exception);
}
assertThat(source)
.contains("List.of(\"FEE_CONFIRMING\", \"WAIT_SPLIT\", \"LOADING\", \"LOADED\", \"DELIVERED\")");
}
private Collection<Object> queryValues(Long tenantId, String ruleCode) {
when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionSqlContractTest {
@Test
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
"phase3-smart-loading-schema.sql"), StandardCharsets.UTF_8));
assertContract(Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8));
}
private static void assertContract(String sql) {
assertThat(sql)
.contains("fl_loading_completion_command")
.contains("fl_loading_completion_checkpoint")
.contains("uk_loading_completion_operation")
.contains("idx_loading_completion_dispatch")
.contains("PROCESSING","RETRYABLE","FAILED","COMPLETED")
.contains("COMPLETING","COMPLETION_FAILED")
.contains("READY","SETTLED","INBOUND_COMPLETED")
.contains("lease_owner","lease_until","next_retry_at")
.contains("failure_stage","failure_code","failure_request_id","failure_trace_id");
}
}
@@ -63,7 +63,7 @@ class LoadingPickItemMapperContractTest {
.contains("plan_item.loading_plan_id = #{planId}")
.contains("plan_item.deleted = 0")
.contains("pick_task.loading_plan_id = #{planId}")
.contains("pick_task.task_status = 'LOADING'")
.doesNotContain("pick_task.task_status")
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.picked_package_count = plan_item.package_count")
.contains("pick_item.item_status = 'PARTIAL'")
@@ -27,12 +27,14 @@ class LoadingPickPdaTaskSqlContractTest {
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("task.task_status = 'LOADING'")
.contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')")
.contains("booking.record_status = 'ACTIVE'")
.contains("booking.source_type AS sourceType")
.contains("booking.record_status AS recordStatus")
.contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId")
.contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'")
.contains("WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'")
.contains("WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'")
.contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'")
.contains("pick_item.item_status IN ('PENDING', 'PICKING')")
.contains("THEN 'AWAITING_REVIEW'")
@@ -229,10 +229,15 @@ class SmartLoadingSqlContractTest {
}
private static void assertLoadingCompletionTrigger(String sql) {
assertThat(sql)
int triggerStart = sql.indexOf("CREATE TRIGGER trg_loading_plan_before_update");
int triggerEnd = sql.indexOf("DROP TRIGGER IF EXISTS trg_loading_plan_after_update", triggerStart);
assertThat(triggerStart).isGreaterThanOrEqualTo(0);
assertThat(triggerEnd).isGreaterThan(triggerStart);
String beforeUpdateTrigger = sql.substring(triggerStart, triggerEnd);
assertThat(beforeUpdateTrigger)
.contains("AND NOT EXISTS (")
.contains("pick_task.loading_plan_id = OLD.id")
.contains("pick_task.task_status = 'LOADING'")
.doesNotContain("pick_task.task_status")
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.item_status = 'PARTIAL'")
.contains("pick_item.picked_package_count > 0")
@@ -308,7 +313,7 @@ class SmartLoadingSqlContractTest {
.contains("container_booking_id BIGINT UNSIGNED NOT NULL")
.contains("cabinet_no VARCHAR(64) NOT NULL")
.contains("container_no VARCHAR(64) NULL")
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
.contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)")
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
@@ -217,7 +217,7 @@ class ContainerFeeDetailServiceTest {
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE"));
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of()));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
@@ -65,7 +66,7 @@ class ContainerServiceTest {
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE"));
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of()));
}
@Test
@@ -154,12 +155,63 @@ class ContainerServiceTest {
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-1",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
}
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
verify(transportHistories).insert(history.capture());
assertThat(history.getValue().getBeforeContainerNo()).isEqualTo("MSCU1234567");
assertThat(history.getValue().getAfterVoyageNo()).isEqualTo("V002");
assertThat(history.getValue().getOperationId()).hasSize(64);
assertThat(history.getValue().getRequestHash()).hasSize(64);
}
@Test
@DisplayName("运输字段没有有效变化时不递增版本且不写历史")
void ignoresTransportNoOp() {
ContainerBookingEntity current = booking("LOADING", 2);
current.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current);
ContainerBookingEntity result = service.updateTransport(11L, 9L, "admin", "transport-noop",
new TransportUpdateRequest(" MSCU1234567 ", " V002 ", 2));
assertThat(result).isSameAs(current);
verify(repository, never()).updateTransport(anyLong(), any(), any(), anyInt(), anyLong(), any());
verify(transportHistories, never()).insert(any(ContainerTransportHistoryEntity.class));
verifyNoInteractions(loadingPickService);
}
@Test
@DisplayName("运输字段精确幂等重放零新增")
void replaysTransportWithoutSecondMutationOrHistory() {
ContainerBookingEntity current = booking("LOADING", 2);
ContainerBookingEntity updated = booking("LOADING", 3);
updated.setVoyageNo("V002");
when(repository.find(11L)).thenReturn(current, updated, updated);
when(repository.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin"))
.thenReturn(1);
ArgumentCaptor<ContainerTransportHistoryEntity> history =
ArgumentCaptor.forClass(ContainerTransportHistoryEntity.class);
TransportUpdateRequest request = new TransportUpdateRequest("MSCU1234567", "V002", 2);
service.updateTransport(11L, 9L, "admin", "transport-replay", request);
verify(transportHistories).insert(history.capture());
when(transportHistories.selectOperation(eq(11L), eq(9L), anyString()))
.thenReturn(history.getValue());
ContainerBookingEntity replay = service.updateTransport(
11L, 9L, "admin", "transport-replay", request);
assertThat(replay.getVersion()).isEqualTo(3);
verify(repository, org.mockito.Mockito.times(1))
.updateTransport(11L, "MSCU1234567", "V002", 2, 9L, "admin");
verify(transportHistories, org.mockito.Mockito.times(1))
.insert(any(ContainerTransportHistoryEntity.class));
}
@Test
@@ -175,7 +227,7 @@ class ContainerServiceTest {
.thenReturn(1);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-2",
new TransportUpdateRequest("MSCU1234567", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
@@ -195,7 +247,7 @@ class ContainerServiceTest {
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
service.updateTransport(11L, 9L, "admin",
service.updateTransport(11L, 9L, "admin", "transport-3",
new TransportUpdateRequest("MSCU7654321", "V002", 2));
verify(loadingPickService).beginIfReady(11L, 9L, "admin");
@@ -211,7 +263,7 @@ class ContainerServiceTest {
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-4",
new TransportUpdateRequest(null, "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
@@ -225,7 +277,7 @@ class ContainerServiceTest {
void rejectsConfirmedUpdate() {
when(repository.find(11L)).thenReturn(booking("COMPLETED", 2));
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin", "transport-5",
new TransportUpdateRequest("MSCU1234567", "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("全部只读");
@@ -172,6 +172,33 @@ class FbaPricingServiceTest {
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@DisplayName("装柜完成后的自动费用仍可确认")
void confirmsCalculatedFeeAfterLoadingCompleted() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("LOADED");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_CONFIRM", "confirm-loaded"))
.thenReturn(null);
doAnswer(invocation -> {
((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L);
return null;
}).when(repository).insertIdempotency(any());
when(repository.confirmFee(TENANT, 501L, OPERATOR, NOW)).thenReturn(1);
when(repository.markInboundConfirmed(TENANT, INBOUND)).thenReturn(1);
when(repository.completeIdempotency(any(), eq(501L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 501L)).thenReturn(fee);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-loaded");
assertThat(response.nextProcessStatus()).isEqualTo("LOADED");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@Test
@DisplayName("审核未通过的测量快照禁止计费")
void rejectsUnapprovedMeasurementSnapshot() {
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
@@ -59,8 +60,8 @@ class InboundMeasurementServiceTest {
@BeforeEach
void stubWarehousePalletSnapshots() {
when(repository.updateInboundWeightSummary(any())).thenReturn(true);
when(warehousePallets.palletSnapshots("internal-token", 100L)).thenReturn(
ApiResponse.success(List.of(palletSnapshot())));
when(warehousePallets.palletSnapshot("internal-token", 501L, 100L)).thenReturn(
ApiResponse.success(palletSnapshot()));
when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot(
"REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123")));
@@ -238,20 +239,28 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
List<InboundMeasurementResponses.PalletSnapshot> result =
service.weightPalletOptions(1L, 10L);
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(palletSnapshot()), 1, 20, 1000, 50)));
assertThat(result).singleElement().satisfies(snapshot -> {
PageResult<InboundMeasurementResponses.PalletSnapshot> result =
service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5");
assertThat(result.records()).singleElement().satisfies(snapshot -> {
assertThat(snapshot.palletId()).isEqualTo(501L);
assertThat(snapshot.tareWeightKg()).isEqualByComparingTo("18.500");
});
verify(warehousePallets).palletSnapshots("internal-token", 100L);
assertThat(result.total()).isEqualTo(1000);
assertThat(result.records()).hasSize(1);
verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5");
}
@Test
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(warehousePallets.palletSnapshot("internal-token", 999L, 100L)).thenReturn(
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.NOT_FOUND,
"所选托盘不属于当前入库仓库或当前不可用", null, null));
assertThatThrownBy(() -> service.createWeightResult(1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
@@ -263,6 +272,29 @@ class InboundMeasurementServiceTest {
verify(repository, never()).insertWeight(any());
}
@Test
void detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
InboundWeightResultEntity historical = weight(50L, "30.000", "KG", "CONFIRMED", 2);
historical.setPalletCode("HISTORICAL-501");
historical.setPalletType("LEGACY-STANDARD");
historical.setPalletTareWeightKg(new BigDecimal("18.500"));
historical.setGrossWeightKg(new BigDecimal("30.000"));
historical.setNetWeightKg(new BigDecimal("11.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(historical));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.weightResults()).singleElement().satisfies(weight -> {
assertThat(weight.palletCode()).isEqualTo("HISTORICAL-501");
assertThat(weight.palletType()).isEqualTo("LEGACY-STANDARD");
assertThat(weight.palletTareWeightKg()).isEqualByComparingTo("18.500");
});
verify(warehousePallets, never()).palletSnapshot(any(), anyLong(), anyLong());
verify(warehousePallets, never()).palletSnapshots(any(), anyLong(), anyLong(), anyLong(), any());
}
@Test
void confirmingLbWeightConvertsToKgAndRecalculatesOnlyConfirmedImages() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest {
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101)));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"),
pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"),
pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
@@ -59,6 +63,15 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11");
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20);
assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2");
assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4");
assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30");
assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@@ -72,6 +85,22 @@ class LoadingCompletedOutboxServiceTest {
.hasMessageContaining("缺少 SKU");
}
@Test void rejectsMissingTrustedBookingCustomerName() {
Row value = row(11, 100, 101);
value.setCustomerName(" ");
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照");
}
@Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() {
Row value = row(11, 100, 101);
value.setBookingCustomerId(22L);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致");
}
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("FBA");
@@ -118,6 +147,41 @@ class LoadingCompletedOutboxServiceTest {
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
}
@Test void immutableCustomerNameParticipatesInPayloadHash() {
Row value = row(11, 100, 101);
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
value.setCustomerName("客户甲-更名快照");
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture());
assertThat(captor.getAllValues().get(0).getPayloadHash())
.isNotEqualTo(captor.getAllValues().get(1).getPayloadHash());
}
@Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception {
Row value = row(11, 100, 101);
value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null);
value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null);
value.setEstimatedWarehouseArrivalAt(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(snapshots.palletRows(90L)).thenReturn(List.of());
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue();
assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue();
assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue();
assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue();
}
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
Row first = row(11, 100, 101); first.setPackageCount(1);
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
@@ -144,7 +208,11 @@ class LoadingCompletedOutboxServiceTest {
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'");
.contains("o.process_status = 'LOADED'")
.contains("b.customer_id AS booking_customer_id")
.contains("b.customer_name")
.contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at")
.contains("b.estimated_warehouse_arrival_at");
for (String sql : List.of(rowsSql, palletsSql)) {
assertThat(sql)
.contains("actual_pick.loading_plan_item_id = i.id")
@@ -158,6 +226,7 @@ class LoadingCompletedOutboxServiceTest {
.doesNotContain("'PICKING'");
}
assertThat(palletsSql)
.contains("a.pallet_id", "a.reservation_id")
.contains("label.tenant_id = i.account_id")
.contains("label.inbound_detail_id = i.inbound_detail_id")
.contains("label.batch_status = 'ACTIVE'")
@@ -219,20 +288,26 @@ class LoadingCompletedOutboxServiceTest {
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account);
r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0));
r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30));
r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0));
r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L);
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
r.setDestinationType("OVERSEAS");
r.setFulfillmentType("STANDARD");
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2"));
r.setVolumeCbm(BigDecimal.ONE); return r;
}
private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated,
long detail,String sku,int packages,int quantity,String boxCode) {
PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound);
row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation);
row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated);
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
@@ -0,0 +1,74 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(LoadingCompletionWorker.class)
class LoadingCompletionWorkerTest {
@MockBean private LoadingCompletionCommandRepository commands;
@MockBean private LoadingPickRepository picks;
@MockBean private LoadingPickService loadingPicks;
@jakarta.annotation.Resource private LoadingCompletionWorker worker;
@Test
void transientFailureIsReleasedForBackoffRetry() {
LoadingCompletionCommandEntity command = command(1);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
doThrow(new LoadingPickService.CompletionProcessingException("WAREHOUSE_SETTLEMENT",
new BizException(ResultCode.SERVICE_UNAVAILABLE, "warehouse timeout")))
.when(loadingPicks).processCompletion(eq(command), any(String.class));
when(commands.fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null)))
.thenReturn(true);
worker.dispatch();
verify(commands).fail(eq(900L), any(String.class), eq("RETRYABLE"), any(LocalDateTime.class),
eq("WAREHOUSE_SETTLEMENT"), eq(ResultCode.SERVICE_UNAVAILABLE.code()), eq(null), eq(null));
}
@Test
void fifthFailureBecomesTerminalAndMarksTaskFailed() {
LoadingCompletionCommandEntity command = command(4);
when(commands.due(10)).thenReturn(List.of(command));
when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true);
doThrow(new LoadingPickService.CompletionProcessingException("PLAN_COMPLETE",
new BizException(ResultCode.CONFLICT, "plan conflict")))
.when(loadingPicks).processCompletion(eq(command), any(String.class));
when(commands.fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null))).thenReturn(true);
worker.dispatch();
verify(commands).fail(eq(900L), any(String.class), eq("FAILED"), any(LocalDateTime.class),
eq("PLAN_COMPLETE"), eq(ResultCode.CONFLICT.code()), eq(null), eq(null));
verify(picks).failCompletion(20L);
}
private static LoadingCompletionCommandEntity command(int attemptCount) {
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
command.setId(900L);
command.setPickTaskId(20L);
command.setOperationId("complete-1");
command.setAttemptCount(attemptCount);
return command;
}
}
@@ -42,6 +42,7 @@ import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("预装与装柜清单状态服务")
@@ -102,6 +103,25 @@ class LoadingListServiceTest {
eq("operator"), eq("补录箱号"));
}
@Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("LOADING", 3);
before.setContainerNo("CSLU1234567");
before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.preloadDetail(before)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "noop-key",
new TransportUpdateRequest(" CSLU1234567 ", " 023E ", 3, "重复保存"));
verify(repository, never()).updateTransport(anyLong(), org.mockito.ArgumentMatchers.anyInt(),
any(), any(), anyLong(), any());
verify(repository, never()).appendHistory(anyLong(), anyString(), anyString(), any(), any(),
any(), any(), anyLong(), any(), any());
verifyNoInteractions(loadingPickService);
}
@Test
void pushedEditingPreloadCanReceiveContainerNumber() {
AggregateRow before = row("EDITING", 3);
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
@@ -26,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
@@ -34,6 +36,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -44,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -53,6 +57,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.clearInvocations;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class)
@@ -60,6 +65,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@@ -85,9 +92,13 @@ class LoadingPickServiceTest {
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION");
booking.setRecordStatus("ACTIVE");
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
}
@Test
@@ -232,6 +243,7 @@ class LoadingPickServiceTest {
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
@@ -414,6 +426,37 @@ class LoadingPickServiceTest {
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
verify(completionCommands, never()).latest(20L);
}
@Test
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of());
doThrow(new IllegalStateException("completion command schema unavailable"))
.when(completionCommands).latest(20L);
var result = service.task(20L, 101L);
assertThat(result.taskStatus()).isEqualTo("LOADING");
assertThat(result.completionCommand()).isNull();
verify(completionCommands, never()).latest(20L);
}
@Test
void taskDetailRejectsWarehouseThatDoesNotOwnTheContainer() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
assertThatThrownBy(() -> service.task(20L, 999L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前仓库");
verify(repository, never()).items(20L, 999L);
verify(repository, never()).cartonLabels(20L);
verify(completionCommands, never()).latest(20L);
}
@Test
@@ -488,6 +531,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -538,6 +582,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -568,6 +613,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -592,6 +638,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -613,6 +660,199 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"释放库位资源时发生并发变更"))
.when(settlementService).settle(20L, 9L, "operator");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("释放库位资源时发生并发变更");
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
Task result = service.complete(20L, new CompleteLoadingRequest(1,
"complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c",
"PDA确认装柜完成"), 9L, "operator");
assertThat(result.id()).isEqualTo(20L);
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).insert(org.mockito.ArgumentMatchers.argThat(
(LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType())));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() {
LoadingPickTaskEntity loading = task();
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
completing.setVersion(1);
AtomicReference<com.baiyue.firstleg.entity.LoadingCompletionCommandEntity> accepted = new AtomicReference<>();
when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get());
doAnswer(invocation -> {
accepted.set(invocation.getArgument(0));
return 1;
}).when(completionCommands).insert(any());
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task first = service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator");
verify(repository).taskForUpdate(20L);
clearInvocations(repository);
Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () ->
service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"));
assertThat(first.taskStatus()).isEqualTo("COMPLETING");
assertThat(first.workflowStatus()).isEqualTo("PROCESSING");
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
verify(repository, never()).taskForUpdate(20L);
verify(completionCommands, times(1)).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void differentOperationIdIsRejectedImmediatelyWithConflict() {
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
LoadingPickTaskEntity loading = task();
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
active.setOperationId("already-processing");
when(completionCommands.active(20L)).thenReturn(active);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令");
verify(completionCommands, never()).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() {
LoadingPickTaskEntity failed = task();
failed.setTaskStatus("COMPLETION_FAILED");
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
var command = completionCommand(902L, "resume-same-operation");
command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070");
command.setCommandStatus("FAILED");
command.setExpectedTaskVersion(0);
when(completionCommands.operation("resume-same-operation")).thenReturn(command);
when(completionCommands.resume(902L)).thenReturn(true);
when(repository.taskForUpdate(20L)).thenReturn(failed);
when(repository.resumeCompletion(20L)).thenReturn(1);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task result = service.complete(20L,
new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator");
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
verify(completionCommands).resume(902L);
verify(repository).resumeCompletion(20L);
verify(completionCommands, never()).insert(any());
}
@Test
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
List<LoadingPickItemMapper.InboundOwner> owners = java.util.stream.IntStream.range(0, 15)
.mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList();
when(repository.inboundOwners(20L)).thenReturn(owners);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.review(20L)).thenReturn(null);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(900L, "worker-1")).thenReturn(true);
var command = completionCommand(900L, "async-15");
service.processCompletion(command, "worker-1");
ArgumentCaptor<Long> inboundIds = ArgumentCaptor.forClass(Long.class);
verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator"));
assertThat(inboundIds.getAllValues()).containsExactly(
1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L,
1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L);
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).completeAccepted(20L, 9L, "operator");
verify(completionCommands).complete(900L, "worker-1");
}
@Test
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(101L, 60L)));
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED");
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(901L, "worker-2")).thenReturn(true);
service.processCompletion(completionCommand(901L, "async-resume"), "worker-2");
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
anyLong(), anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
"PDA-async-resume-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(
operation -> "COMPLETE_LOADING".equals(operation.getActionType())));
}
@Test
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
LoadingPickTaskEntity task = task();
@@ -814,6 +1054,21 @@ class LoadingPickServiceTest {
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand(
long id, String operationId) {
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
command.setId(id);
command.setPickTaskId(20L);
command.setOperationId(operationId);
command.setRequestHash("hash");
command.setCommandStatus("PROCESSING");
command.setOperatorId(9L);
command.setOperatorName("operator");
command.setRemark("checked");
command.setAttemptCount(1);
return command;
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);
@@ -179,10 +179,13 @@ class PdaPickSettlementServiceTest {
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 10), allocation(2L, 2, 502L, 10)));
InboundPalletAllocationEntity first = allocation(1L, 1, 501L, 10);
InboundPalletAllocationEntity second = allocation(2L, 2, 501L, 10);
first.setPalletId(801L);
second.setPalletId(801L);
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(first, second));
when(warehouse.settle(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 502L)),
new WarehousePalletLocationClient.SettlementResult(List.of(released(91L, 501L), released(92L, 501L)),
List.of())));
writes(0, 20);
@@ -380,6 +380,30 @@ class SmartLoadingServiceTest {
anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void reusesConfirmingPreloadWhenAddingToRunningPlan() {
LoadingPlanEntity loading = plan("LOADING", 5);
LoadingPlanEntity after = plan("LOADING", 6);
when(repository.plan(1)).thenReturn(loading, after);
when(repository.booking(99)).thenReturn(booking());
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "goods", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-confirming"), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u");
verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
7L, "u", "assign-confirming");
}
@Test
void replaysBeforeVersionCheck() {
AssignRequest request = new AssignRequest(10L, 20L, 2, 0, "assign-replay");
@@ -503,6 +527,26 @@ class SmartLoadingServiceTest {
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
}
@Test
void unchangedWarehouseRemarkDoesNotTouchPlanItemOrHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
line.setWarehouseRemark("待核对外箱");
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" 待核对外箱 ", 2, 0, "warehouse-noop"), 7L, "u");
assertThat(result.version()).isEqualTo(2);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
@@ -605,6 +649,30 @@ class SmartLoadingServiceTest {
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void unchangedQuantityAndCargoSnapshotDoNotWriteAdjustmentHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 2);
line.setGrossWeightKg(new BigDecimal("4.00"));
line.setNetWeightKg(new BigDecimal("3.0"));
line.setVolumeCbm(new BigDecimal("1.000"));
line.setPalletCount(0);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
Plan result = service.adjust(1L, 8L,
new AdjustRequest(2, 2, 0, "adjust-noop"), 7L, "u");
assertThat(result.version()).isEqualTo(2);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).update(any(LoadingPlanItemEntity.class));
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(loadingPickService, never()).sync(anyLong(), any(LoadingPlanItemEntity.class));
}
@Test
void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() {
LoadingPlanEntity plan = plan("LOADING", 2);
@@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = {
@@ -50,12 +51,29 @@ class TaillegRealProducerAcceptanceTest {
assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class,
eventKey));
assertEquals("真实验收客户", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertEquals("2026-09-30T12:00", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertNull(jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
assertEquals(1, jdbc.queryForObject(
"SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " +
"FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey));
assertEquals("35.0", jdbc.queryForObject(
"SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " +
"FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey));
}
private void seedCompletedLoadingPlan() {
jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," +
"warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
"warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,1,1)",
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓",
CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
@@ -66,12 +84,8 @@ class TaillegRealProducerAcceptanceTest {
MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3);
for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L,
MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2);
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D",
1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES");
seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F");
seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D");
jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," +
"container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')",
990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR");
@@ -80,6 +94,16 @@ class TaillegRealProducerAcceptanceTest {
jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN);
}
private void seedAllocation(long id, long inboundId, long palletId, long locationId,
int packages, String suffix) {
jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," +
"warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," +
"allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," +
"reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')",
id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix,
1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix);
}
private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) {
jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," +
"inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",
@@ -58,7 +58,7 @@ class TruckAppointmentServiceTest {
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE"));
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
}
@@ -36,7 +36,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE")));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"))));
var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING");
@@ -46,7 +46,7 @@ class WarehouseSupplierSnapshotServiceTest {
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE")));
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of())));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
@@ -55,7 +55,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE")));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of())));
assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
@@ -64,7 +64,7 @@ class WarehouseSupplierSnapshotServiceTest {
void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "错误代理", "TRUCKING", "ACTIVE")));
7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of())));
assertThatThrownBy(() -> service.booking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
@@ -129,13 +129,14 @@ class SplitPreloadServiceTest {
}
@Test
void runningLoadingAllocationStillRejectsConfirmingPreload() {
void runningLoadingAllocationMayReuseConfirmingPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("CONFIRMING");
when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("已进入确认流程");
service.ensureForRunningLoadingAllocation(10, 20, 7, "admin");
verify(repository, never()).createPlan(any());
}
}
@@ -41,7 +41,13 @@ public class NotificationRecipientResolver {
if (user != null && "ACTIVE".equals(user.getStatus())) unique.put(item.getId(), new ResolvedRecipient(
item.getTenantId(), item.getId(), item.getUserId(), user.getDisplayName(), item.getTenantId() == 0 ? "PLATFORM" : "MERCHANT"));
});
if (unique.isEmpty()) fail("NOTIFICATION_RECIPIENT_EMPTY");
if (unique.isEmpty()) {
if (request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.TENANT_ROLE
|| request.recipientType() == com.baiyue.iam.notification.dto.NotificationDtos.RecipientType.PLATFORM_ROLE) {
fail("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员");
}
fail("未找到可接收消息的有效成员,请检查用户 ID 和目标企业");
}
return List.copyOf(unique.values());
}
@@ -49,7 +55,7 @@ public class NotificationRecipientResolver {
var role = roles.selectOne(new LambdaQueryWrapper<com.baiyue.iam.entity.IamRole>()
.eq(com.baiyue.iam.entity.IamRole::getTenantId, request.tenantId())
.eq(com.baiyue.iam.entity.IamRole::getRoleCode, request.roleCode()).eq(com.baiyue.iam.entity.IamRole::getStatus,"ACTIVE").last("LIMIT 1"));
if (role == null) fail("NOTIFICATION_RECIPIENT_FORBIDDEN");
if (role == null) fail("角色编码不存在,请检查角色编码是否正确");
List<Long> ids = membershipRoles.selectList(new LambdaQueryWrapper<com.baiyue.iam.entity.IamMembershipRole>()
.eq(com.baiyue.iam.entity.IamMembershipRole::getTenantId,request.tenantId())
.eq(com.baiyue.iam.entity.IamMembershipRole::getRoleId,role.getId())).stream()
@@ -29,7 +29,7 @@ public class RbacRepository {
public IamRole findRole(Long id, long tenantId, String appCode) {
return roleMapper.selectOne(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getId, id).eq(IamRole::getTenantId, tenantId)
.eq(IamRole::getId, id).in(IamRole::getTenantId, List.of(0L, tenantId))
.eq(IamRole::getAppCode, appCode));
}
@@ -80,7 +80,7 @@ public class RbacRepository {
public List<IamRole> findRoles(long tenantId, String appCode) {
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode)
.in(IamRole::getTenantId, List.of(0L, tenantId)).eq(IamRole::getAppCode, appCode)
.orderByAsc(IamRole::getRoleCode));
}
@@ -109,7 +109,7 @@ public class RbacRepository {
public Set<Long> findRolePermissionIds(long tenantId, Long roleId) {
List<IamRolePermission> rows = rolePermissionMapper.selectList(
new LambdaQueryWrapper<IamRolePermission>()
.eq(IamRolePermission::getTenantId, tenantId)
.in(IamRolePermission::getTenantId, List.of(0L, tenantId))
.eq(IamRolePermission::getRoleId, roleId)
.orderByAsc(IamRolePermission::getPermissionId));
Set<Long> result = new LinkedHashSet<>();
@@ -64,7 +64,8 @@ public class MemberService {
List<Long> roleIds = repository.findRoleIds(tenantId, membership.getId());
List<String> roleNames = roleIds.stream()
.map(roleMapper::selectById)
.filter(role -> role != null && Long.valueOf(tenantId).equals(role.getTenantId()))
.filter(role -> role != null && "MERCHANT".equals(role.getAppCode())
&& (Long.valueOf(tenantId).equals(role.getTenantId()) || Long.valueOf(0L).equals(role.getTenantId())))
.map(IamRole::getRoleName)
.toList();
return new PlatformCustomerMemberDtos.MemberResponse(membership.getId(), user.getId(),
@@ -85,3 +85,10 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE'
WHERE p.tenant_id=0 AND p.permission_code LIKE 'platform:notification%' AND r.role_code='PLATFORM_ADMIN';
-- All active platform employee roles can receive and read their own inbox messages.
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_permission p JOIN iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE'
WHERE p.tenant_id=0
AND p.permission_code IN ('platform:notification:view','platform:notification:read','platform:notification:read-all');
@@ -145,7 +145,7 @@ components:
tenantId: {type: integer, format: int64, minimum: 0}
templateCode: {type: string, maxLength: 64}
recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]}
recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.}
recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types. For a specified enterprise member, pass the user's ID and use USER; membership IDs are reserved for explicit membership-level calls.}
roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.}
variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}}
scheduledAt: {type: string, format: date-time, nullable: true}
@@ -0,0 +1,101 @@
package com.baiyue.iam.notification;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamMembershipMapper;
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.mapper.IamUserMapper;
import com.baiyue.iam.notification.dto.NotificationDtos.CreateNotificationRequest;
import com.baiyue.iam.notification.dto.NotificationDtos.RecipientType;
import com.baiyue.iam.notification.service.NotificationRecipientResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class NotificationRecipientResolverTest {
private final IamMembershipMapper memberships = mock(IamMembershipMapper.class);
private final IamUserMapper users = mock(IamUserMapper.class);
private final IamRoleMapper roles = mock(IamRoleMapper.class);
private final IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class);
private final NotificationRecipientResolver resolver = new NotificationRecipientResolver();
@BeforeEach
void setup() {
ReflectionTestUtils.setField(resolver, "memberships", memberships);
ReflectionTestUtils.setField(resolver, "users", users);
ReflectionTestUtils.setField(resolver, "roles", roles);
ReflectionTestUtils.setField(resolver, "membershipRoles", membershipRoles);
}
@Test
void resolvesSpecifiedEnterpriseMemberByUserId() {
IamMembership membership = new IamMembership();
membership.setId(301L);
membership.setTenantId(9L);
membership.setUserId(2092537408743079938L);
membership.setStatus("ACTIVE");
IamUser user = new IamUser();
user.setId(membership.getUserId());
user.setDisplayName("测试成员");
user.setStatus("ACTIVE");
when(memberships.selectList(any())).thenReturn(List.of(membership));
when(users.selectById(membership.getUserId())).thenReturn(user);
var recipients = resolver.resolve(request(RecipientType.USER, List.of(membership.getUserId())));
assertThat(recipients).containsExactly(new NotificationRecipientResolver.ResolvedRecipient(
9L, 301L, membership.getUserId(), "测试成员", "MERCHANT"));
}
@Test
void explainsWhenNoActiveRecipientCanBeResolved() {
when(memberships.selectList(any())).thenReturn(List.of());
assertThatThrownBy(() -> resolver.resolve(request(RecipientType.USER, List.of(2092537408743079938L))))
.isInstanceOf(BizException.class)
.hasMessage("未找到可接收消息的有效成员,请检查用户 ID 和目标企业");
}
@Test
void explainsWhenRoleCodeDoesNotExist() {
when(roles.selectOne(any())).thenReturn(null);
assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "MISSING_ROLE")))
.isInstanceOf(BizException.class)
.hasMessage("角色编码不存在,请检查角色编码是否正确");
}
@Test
void explainsWhenRoleHasNoActiveMembers() {
IamRole role = new IamRole();
role.setId(501L);
when(roles.selectOne(any())).thenReturn(role);
when(membershipRoles.selectList(any())).thenReturn(List.of());
assertThatThrownBy(() -> resolver.resolve(roleRequest(RecipientType.PLATFORM_ROLE, "CUSTOMER_REVIEWER")))
.isInstanceOf(BizException.class)
.hasMessage("该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员");
}
private CreateNotificationRequest request(RecipientType recipientType, List<Long> recipientIds) {
return new CreateNotificationRequest(9L, "ORDER_STATUS_CHANGED", recipientType, recipientIds,
null, Map.of(), null, "notification-test-key", null, null);
}
private CreateNotificationRequest roleRequest(RecipientType recipientType, String roleCode) {
return new CreateNotificationRequest(0L, "ORDER_STATUS_CHANGED", recipientType, null,
roleCode, Map.of(), null, "notification-role-test-key", null, null);
}
}
@@ -71,7 +71,8 @@ class MemberServiceTest {
when(tenantRepository.findTenant(100L)).thenReturn(tenant);
when(repository.findByTenant(100L)).thenReturn(List.of(member));
IamRole ownerRole = new IamRole();
ownerRole.setId(50L); ownerRole.setTenantId(100L); ownerRole.setRoleName("企业管理员");
ownerRole.setId(50L); ownerRole.setTenantId(0L); ownerRole.setRoleName("企业管理员");
ownerRole.setAppCode("MERCHANT");
when(roleMapper.selectById(50L)).thenReturn(ownerRole);
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(40L, "merchant_owner", "企业管理员", "ACTIVE")));
+2 -2
View File
@@ -11,7 +11,7 @@
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;待分配柜级摘要固定为 `/api/platform/v1/last-leg/orders/container-summaries`,按 `sourceContainerBookingId` 跨客户聚合分页,不允许前端本地拼柜 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -21,7 +21,7 @@
| 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 |
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 |
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` |
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 |
@@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.dto.PendingContainerSummary;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.service.LastlegOrderService;
@@ -71,6 +72,18 @@ public class LastlegOrderController {
return ApiResponse.success(orderService.page(platformScope(user, customerId), query));
}
@GetMapping("/api/platform/v1/last-leg/orders/container-summaries")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "分页查询待分配柜级摘要",
description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: "
+ OverseasPermissions.LAST_MILE_ORDER_LIST)
public ApiResponse<PageResult<PendingContainerSummary>> containerSummaries(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid LastlegOrderRequests.PendingContainerQuery query) {
platformScope(user, null);
return ApiResponse.success(orderService.pendingContainerPage(query));
}
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)

Some files were not shown because too many files have changed in this diff Show More