优化
This commit is contained in:
Vendored
+5
-1
@@ -69,7 +69,7 @@ pipeline {
|
|||||||
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
|
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
|
||||||
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
|
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
|
||||||
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)')
|
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由 EVENT_BRIDGE_REGION 选择实例)')
|
||||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅部署 event-bridge-service 时生效;明确选择国内或海外实例')
|
choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '部署 event-bridge-service 时必须明确选择实例;SELECT 会阻止部署')
|
||||||
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
|
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
|
||||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||||
@@ -149,6 +149,10 @@ pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selected = selected.unique()
|
selected = selected.unique()
|
||||||
|
if (params.DEPLOY && selected.contains('event-bridge-service')
|
||||||
|
&& !['CN', 'OVERSEAS'].contains(params.EVENT_BRIDGE_REGION)) {
|
||||||
|
error('部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS')
|
||||||
|
}
|
||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ pipeline {
|
|||||||
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
|
||||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||||
choice(name: 'EVENT_BRIDGE_REGION', choices: ['CN', 'OVERSEAS'], description: '仅 event-bridge-service 使用;通常由总控 Job 传入')
|
choice(name: 'EVENT_BRIDGE_REGION', choices: ['SELECT', 'CN', 'OVERSEAS'], description: '仅 event-bridge-service 部署使用;必须由总控明确传入 CN 或 OVERSEAS')
|
||||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
|
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
|
||||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
string(name: 'DOCKER_BASE_IMAGE', defaultValue: '172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine', description: '内网 Java 17 基础镜像')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||||
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;冻结来源/根订单与入仓标识、客户、业务类型、逐明细目的仓/备注/托盘号/实装箱号及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
| 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` |
|
||||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||||
|
|||||||
+15
-2
@@ -16,12 +16,25 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||||
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
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) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||||
|
}
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
String inboundCode, String destinationWarehouseId,
|
String inboundCode, String destinationWarehouseId,
|
||||||
@@ -32,7 +45,7 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -15,13 +15,28 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||||
|
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||||
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
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) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||||
|
}
|
||||||
|
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
String inboundCode, String destinationWarehouseId,
|
String inboundCode, String destinationWarehouseId,
|
||||||
@@ -32,7 +47,7 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
@@ -47,4 +47,17 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
assertThat(restored).isEqualTo(payload);
|
assertThat(restored).isEqualTo(payload);
|
||||||
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -40,6 +40,18 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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");
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
|
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mapsEachOrdinaryCartonNumberToItsSku() {
|
void mapsEachOrdinaryCartonNumberToItsSku() {
|
||||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
|
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2,
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ deploy_local() {
|
|||||||
new_started=false
|
new_started=false
|
||||||
fi
|
fi
|
||||||
if [ "$new_started" = "true" ] && wait_for_local_health; then
|
if [ "$new_started" = "true" ] && wait_for_local_health; then
|
||||||
|
running_image="$(local_container_image || true)"
|
||||||
|
if [ "$running_image" != "$IMAGE" ]; then
|
||||||
|
echo "运行容器镜像与请求镜像不一致: expected=$IMAGE actual=${running_image:-missing}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
echo "新镜像健康检查通过: $IMAGE"
|
echo "新镜像健康检查通过: $IMAGE"
|
||||||
local_compose ps "$SERVICE"
|
local_compose ps "$SERVICE"
|
||||||
local_registry_logout
|
local_registry_logout
|
||||||
@@ -441,6 +446,11 @@ if ! compose up -d --no-deps --pull never "$service"; then
|
|||||||
new_started=false
|
new_started=false
|
||||||
fi
|
fi
|
||||||
if [ "$new_started" = "true" ] && wait_for_health; then
|
if [ "$new_started" = "true" ] && wait_for_health; then
|
||||||
|
running_image="$(container_image || true)"
|
||||||
|
if [ "$running_image" != "$new_image" ]; then
|
||||||
|
echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "新镜像健康检查通过: $new_image"
|
echo "新镜像健康检查通过: $new_image"
|
||||||
compose ps "$service"
|
compose ps "$service"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
|||||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
||||||
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
||||||
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||||
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
||||||
@@ -331,6 +331,53 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||||
|
|
||||||
|
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
|
||||||
|
'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||||
|
'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;
|
||||||
|
|
||||||
|
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'),
|
||||||
|
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''',
|
||||||
|
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||||
|
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||||
|
UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id='';
|
||||||
|
ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL 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='fba_warehouse_code'),
|
||||||
|
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''',
|
||||||
|
'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码'' AFTER inbound_code'));
|
||||||
|
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||||
|
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='destination_type'),
|
||||||
|
'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||||
|
'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code'));
|
||||||
|
PREPARE phase4_receiving_line_stmt FROM @ddl; EXECUTE phase4_receiving_line_stmt; DEALLOCATE PREPARE phase4_receiving_line_stmt;
|
||||||
|
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
|
||||||
|
SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END)
|
||||||
|
WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||||
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键',
|
||||||
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
|
photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
||||||
|
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
|
||||||
|
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||||
|
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||||
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||||
|
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'),
|
||||||
|
'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''',
|
||||||
|
'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||||
|
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||||
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'),
|
||||||
'SELECT 1',
|
'SELECT 1',
|
||||||
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
|
'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status'));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg |
|
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg |
|
||||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||||
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
||||||
| 人工补投 | `service/EventReplayService.java` |
|
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
||||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||||
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ public class EventBridgeController {
|
|||||||
return ApiResponse.success(eventInboxService.receive(envelope));
|
return ApiResponse.success(eventInboxService.receive(envelope));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "人工补投死信事件")
|
@Operation(summary = "人工补投死信事件",
|
||||||
|
description = "仅允许 DEAD 事件转回 RETRY;保留原 eventId、payload 与 payloadHash,必须记录操作人与补投原因")
|
||||||
@PostMapping("/events/{eventId}/replay")
|
@PostMapping("/events/{eventId}/replay")
|
||||||
public ApiResponse<Void> replay(
|
public ApiResponse<Void> replay(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
|||||||
+12
@@ -3,8 +3,10 @@ package com.baiyue.eventbridge.service;
|
|||||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class EventReplayServiceTest {
|
class EventReplayServiceTest {
|
||||||
@@ -28,6 +30,16 @@ class EventReplayServiceTest {
|
|||||||
.isInstanceOf(IllegalStateException.class);
|
.isInstanceOf(IllegalStateException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replayOnlyRequeuesDeadStateAndDoesNotRewriteFrozenEvent() throws Exception {
|
||||||
|
String sql = String.join(" ", BridgeInboxMapper.class
|
||||||
|
.getMethod("replayDead", String.class, String.class)
|
||||||
|
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
|
||||||
|
|
||||||
|
assertThat(sql).contains("status='RETRY'", "retry_count=0", "status='DEAD'")
|
||||||
|
.doesNotContain("payload_json=", "payload_hash=");
|
||||||
|
}
|
||||||
|
|
||||||
private EventReplayService service() {
|
private EventReplayService service() {
|
||||||
EventReplayService service = new EventReplayService();
|
EventReplayService service = new EventReplayService();
|
||||||
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
|
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
|
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
|
||||||
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
||||||
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
||||||
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;按最终实装与稳定箱码冻结 V2,真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||||
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||||
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper {
|
|||||||
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
|
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
|
||||||
o.order_id AS source_order_id, o.order_no AS source_order_no,
|
o.order_id AS source_order_id, o.order_no AS source_order_no,
|
||||||
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
|
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
|
||||||
o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
|
o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
|
||||||
o.destination_name AS destination_warehouse_name,
|
o.destination_name AS destination_warehouse_name,
|
||||||
i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
|
i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
|
||||||
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
|
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
|
||||||
@@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper {
|
|||||||
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
|
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
|
||||||
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
|
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
|
||||||
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
|
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
|
||||||
private String businessType;
|
private String businessType; private String destinationType;
|
||||||
private String customerRemark; private String internalRemark; private String warehouseRemark;
|
private String customerRemark; private String internalRemark; private String warehouseRemark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -103,13 +103,15 @@ public class LoadingCompletedOutboxService {
|
|||||||
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
|
||||||
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||||
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
|
||||||
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
|
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
|
||||||
|
destinationType(sorted));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
|
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
|
||||||
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
|
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
|
||||||
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
|
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
|
||||||
Map<LabelPoolKey, List<Row>> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType()))
|
Map<LabelPoolKey, List<Row>> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType()))
|
||||||
|
.filter(row -> "OVERSEAS".equals(destinationType(List.of(row))))
|
||||||
.collect(java.util.stream.Collectors.groupingBy(
|
.collect(java.util.stream.Collectors.groupingBy(
|
||||||
row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()),
|
row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()),
|
||||||
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||||
@@ -155,6 +157,17 @@ public class LoadingCompletedOutboxService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String destinationType(List<Row> rows) {
|
||||||
|
Set<String> values = rows.stream().map(Row::getDestinationType)
|
||||||
|
.map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT))
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT,
|
||||||
|
"装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件");
|
||||||
|
}
|
||||||
|
return values.iterator().next();
|
||||||
|
}
|
||||||
|
|
||||||
private record LabelPoolKey(long accountId, long inboundId) { }
|
private record LabelPoolKey(long accountId, long inboundId) { }
|
||||||
|
|
||||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
|
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
|
||||||
|
|||||||
+27
@@ -58,6 +58,7 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
|
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
|
||||||
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
|
||||||
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
|
||||||
|
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
|
||||||
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
|
||||||
assertThat(first.eventVersion()).isEqualTo(2);
|
assertThat(first.eventVersion()).isEqualTo(2);
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,31 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
.hasMessageContaining("缺少 SKU");
|
.hasMessageContaining("缺少 SKU");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
|
||||||
|
Row value = row(11, 100, 101);
|
||||||
|
value.setDestinationType("FBA");
|
||||||
|
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||||
|
|
||||||
|
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/destinationType").asText()).isEqualTo("FBA");
|
||||||
|
assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty();
|
||||||
|
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void rejectsUnfrozenDestinationType() {
|
||||||
|
Row value = row(11, 100, 101);
|
||||||
|
value.setDestinationType(null);
|
||||||
|
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.publish(90L))
|
||||||
|
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS");
|
||||||
|
verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception {
|
@Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception {
|
||||||
Row value = row(11, 100, 101);
|
Row value = row(11, 100, 101);
|
||||||
value.setPackageCount(2);
|
value.setPackageCount(2);
|
||||||
@@ -148,6 +174,7 @@ class LoadingCompletedOutboxServiceTest {
|
|||||||
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
|
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
|
||||||
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
|
||||||
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
|
||||||
|
r.setDestinationType("OVERSEAS");
|
||||||
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
|
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
|
||||||
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
|
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -88,9 +88,9 @@ class TaillegRealProducerAcceptanceTest {
|
|||||||
|
|
||||||
private void seedInbound(long id, long orderId, String code, String type, String status, String destination) {
|
private void seedInbound(long id, long orderId, String code, String type, String status, String destination) {
|
||||||
jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," +
|
jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," +
|
||||||
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_warehouse_id," +
|
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," +
|
||||||
"destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " +
|
"destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " +
|
||||||
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,9001,?,?,?,0.4000,1,1)",
|
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)",
|
||||||
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type,
|
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type,
|
||||||
destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
|
destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
|
| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
|
||||||
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
|
||||||
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 |
|
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
|
||||||
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||||
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||||
|
|||||||
+2
-1
@@ -41,7 +41,8 @@ public class LastlegOrderController {
|
|||||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
@PostMapping("/internal/lastleg/events")
|
@PostMapping("/internal/lastleg/events")
|
||||||
@Operation(summary = "Receive a first-leg loading-completed event")
|
@Operation(summary = "Receive a first-leg loading-completed event",
|
||||||
|
description = "V2 orders[].destinationType is FBA or OVERSEAS. FBA may use package counts; OVERSEAS requires one carton mark per loaded package. Legacy events are accepted only when the destination type can be inferred safely.")
|
||||||
public ApiResponse<Integer> receive(
|
public ApiResponse<Integer> receive(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@RequestBody EventEnvelope event) {
|
@RequestBody EventEnvelope event) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ public record LastlegOrderSummary(
|
|||||||
String orderNo,
|
String orderNo,
|
||||||
String orderType,
|
String orderType,
|
||||||
String orderTypeName,
|
String orderTypeName,
|
||||||
|
@Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"})
|
||||||
|
String destinationType,
|
||||||
String sourceInboundId,
|
String sourceInboundId,
|
||||||
String inboundCode,
|
String inboundCode,
|
||||||
String sourceOrderNo,
|
String sourceOrderNo,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class LastlegInboundOrder {
|
|||||||
private String destinationWarehouseId;
|
private String destinationWarehouseId;
|
||||||
private String destinationWarehouseCode;
|
private String destinationWarehouseCode;
|
||||||
private String destinationWarehouseName;
|
private String destinationWarehouseName;
|
||||||
|
private String destinationType;
|
||||||
private Integer packageCount;
|
private Integer packageCount;
|
||||||
private Integer productQuantity;
|
private Integer productQuantity;
|
||||||
private Integer palletCount;
|
private Integer palletCount;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public class LastlegOrder {
|
|||||||
private String orderNo;
|
private String orderNo;
|
||||||
private String customerId;
|
private String customerId;
|
||||||
private String orderType;
|
private String orderType;
|
||||||
|
private String destinationType;
|
||||||
private String inboundCode;
|
private String inboundCode;
|
||||||
private String sourceRegion;
|
private String sourceRegion;
|
||||||
private String sourceInboundId;
|
private String sourceInboundId;
|
||||||
|
|||||||
+1
@@ -16,6 +16,7 @@ public class InboundPlanLine {
|
|||||||
private Long planId;
|
private Long planId;
|
||||||
private Long orderId;
|
private Long orderId;
|
||||||
private String inboundCode;
|
private String inboundCode;
|
||||||
|
private String destinationType;
|
||||||
private String fbaWarehouseCode;
|
private String fbaWarehouseCode;
|
||||||
private String destinationWarehouseId;
|
private String destinationWarehouseId;
|
||||||
private String destinationWarehouseCode;
|
private String destinationWarehouseCode;
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/pda/v1/last-leg/receiving-containers")
|
||||||
|
@Tag(name = "Overseas PDA container receiving")
|
||||||
|
public class ReceivingContainerController {
|
||||||
|
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||||
|
@Resource private ReceivingContainerService service;
|
||||||
|
|
||||||
|
@GetMapping("/candidates")
|
||||||
|
@Operation(summary = "List pending receiving containers for the authenticated customer")
|
||||||
|
public ApiResponse<List<ReceivingContainerResponses.Candidate>> candidates(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{containerNo}")
|
||||||
|
@Operation(summary = "Get one receiving container without exposing plan or allocation IDs")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Session> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String containerNo) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{containerNo}/prepare")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Session> prepare(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(),
|
||||||
|
containerNo, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestPart("file") MultipartFile file) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required");
|
||||||
|
return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/sign")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/current")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Session> current(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/cartons/resolve")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.CartonResolved> resolveCarton(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.CartonResolve request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/pallets/resolve")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.PalletResolve request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/receipts/bulk")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveBulk(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.BulkReceive request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/receipts/carton")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Receipt> receiveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.CartonReceive request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/current/complete")
|
||||||
|
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||||
|
Identity identity = identity(user);
|
||||||
|
return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Identity identity(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required");
|
||||||
|
}
|
||||||
|
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||||
|
}
|
||||||
|
String operator = String.valueOf(user.userId());
|
||||||
|
return new Identity(String.valueOf(user.tenantId()), operator,
|
||||||
|
user.username() == null || user.username().isBlank() ? operator : user.username());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Identity(String customerId, String operatorKey, String operatorName) { }
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ReceivingContainerRequests {
|
||||||
|
private ReceivingContainerRequests() { }
|
||||||
|
|
||||||
|
public record Action(@NotBlank String idempotencyKey) { }
|
||||||
|
public record Sign(@NotEmpty List<@Positive Long> photoFileIds, @NotBlank String idempotencyKey) { }
|
||||||
|
public record CartonResolve(@NotBlank String cartonMark) { }
|
||||||
|
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
|
||||||
|
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
|
||||||
|
@Positive int packageCount, @NotBlank String idempotencyKey) { }
|
||||||
|
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
|
||||||
|
@NotBlank String idempotencyKey) { }
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ReceivingContainerResponses {
|
||||||
|
private ReceivingContainerResponses() { }
|
||||||
|
|
||||||
|
public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount,
|
||||||
|
int totalPackages, int receivedPackages, boolean currentAccount) { }
|
||||||
|
|
||||||
|
public record Session(String containerNo, String status, String operatorName, int version,
|
||||||
|
List<Long> photoFileIds, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||||
|
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||||
|
int totalPackages, int receivedPackages, int pendingPackages,
|
||||||
|
List<Inbound> inbounds) { }
|
||||||
|
|
||||||
|
public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||||
|
int receivedPackages, int pendingPackages) { }
|
||||||
|
|
||||||
|
public record CartonResolved(String cartonMark, String skuCode, String inboundCode,
|
||||||
|
String destinationType, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||||
|
int receivedPackages, int pendingPackages) { }
|
||||||
|
|
||||||
|
public record PalletResolved(String palletCode, long warehouseId, String status,
|
||||||
|
String fbaWarehouseCode, String inboundCode,
|
||||||
|
String destinationType, boolean compatible) { }
|
||||||
|
|
||||||
|
public record Receipt(String result, String containerNo, String inboundCode,
|
||||||
|
String destinationType, String palletCode, int acceptedPackages,
|
||||||
|
int receivedPackages, int pendingPackages, String sessionStatus) { }
|
||||||
|
|
||||||
|
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_receiving_container_operation")
|
||||||
|
public class ReceivingContainerOperation {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private Long sessionId;
|
||||||
|
private String customerId;
|
||||||
|
private String operatorKey;
|
||||||
|
private String action;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String requestJson;
|
||||||
|
private String resultJson;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.baomidou.mybatisplus.annotation.Version;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_receiving_container_session")
|
||||||
|
public class ReceivingContainerSession {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String containerNo;
|
||||||
|
private String operatorKey;
|
||||||
|
private String operatorName;
|
||||||
|
private String activeOperatorKey;
|
||||||
|
private String activeContainerKey;
|
||||||
|
private String status;
|
||||||
|
private String photoFileIds;
|
||||||
|
private LocalDateTime preparedAt;
|
||||||
|
private LocalDateTime signedAt;
|
||||||
|
private LocalDateTime receivingAt;
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
@Version
|
||||||
|
private Integer version;
|
||||||
|
}
|
||||||
+19
-2
@@ -35,7 +35,9 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
|||||||
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
|
throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode());
|
||||||
}).body(WarehousePalletResponse.class);
|
}).body(WarehousePalletResponse.class);
|
||||||
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
|
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
|
||||||
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId());
|
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
|
||||||
|
response.data().warehouseId(), response.data().locationId(), response.data().status(),
|
||||||
|
response.data().fbaWarehouseCode(), response.data().version());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,8 +84,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
|||||||
.toBodilessEntity();
|
.toBodilessEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WarehousePalletReference resolvePallet(String palletCode) {
|
||||||
|
WarehousePalletResponse response = client.get()
|
||||||
|
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode)
|
||||||
|
.header("X-Internal-Service-Token", internalToken)
|
||||||
|
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||||
|
throw new IllegalArgumentException("Warehouse rejected pallet code: HTTP " + result.getStatusCode());
|
||||||
|
}).body(WarehousePalletResponse.class);
|
||||||
|
if (response == null || response.data() == null) throw new IllegalArgumentException("Warehouse returned an empty pallet response");
|
||||||
|
return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
|
||||||
|
response.data().warehouseId(), response.data().locationId(), response.data().status(),
|
||||||
|
response.data().fbaWarehouseCode(), response.data().version());
|
||||||
|
}
|
||||||
|
|
||||||
private record WarehousePalletResponse(WarehousePalletData data) {}
|
private record WarehousePalletResponse(WarehousePalletData data) {}
|
||||||
private record WarehousePalletData(long id, String palletCode, long locationId) {}
|
private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId,
|
||||||
|
String status, String fbaWarehouseCode, int version) {}
|
||||||
private record ReserveEnvelope(ReserveData data) {}
|
private record ReserveEnvelope(ReserveData data) {}
|
||||||
private record ReserveData(long lockId) {}
|
private record ReserveData(long lockId) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -6,6 +6,12 @@ public interface WarehouseResourceGateway {
|
|||||||
String businessType, String businessId, String reservationKey);
|
String businessType, String businessId, String reservationKey);
|
||||||
void scrap(String customerId, long resourceLockId);
|
void scrap(String customerId, long resourceLockId);
|
||||||
void scrapPallet(String customerId, long palletId);
|
void scrapPallet(String customerId, long palletId);
|
||||||
|
WarehousePalletReference resolvePallet(String palletCode);
|
||||||
|
|
||||||
record WarehousePalletReference(long palletId, String palletNo, long locationId) {}
|
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
|
||||||
|
String status, String fbaWarehouseCode, int version) {
|
||||||
|
public WarehousePalletReference(long palletId, String palletNo, long locationId) {
|
||||||
|
this(palletId, palletNo, 0L, locationId, null, null, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ReceivingContainerOperationMapper extends BaseMapper<ReceivingContainerOperation> {
|
||||||
|
@Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " +
|
||||||
|
"AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
ReceivingContainerOperation find(@Param("customerId") String customerId,
|
||||||
|
@Param("operatorKey") String operatorKey,
|
||||||
|
@Param("key") String key);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingContainerSession> {
|
||||||
|
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||||
|
"AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE")
|
||||||
|
ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId,
|
||||||
|
@Param("activeOperatorKey") String activeOperatorKey);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||||
|
"AND active_operator_key=#{activeOperatorKey} LIMIT 1")
|
||||||
|
ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId,
|
||||||
|
@Param("activeOperatorKey") String activeOperatorKey);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " +
|
||||||
|
"AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE")
|
||||||
|
ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId,
|
||||||
|
@Param("activeContainerKey") String activeContainerKey);
|
||||||
|
}
|
||||||
+81
-2
@@ -135,6 +135,74 @@ public class InboundPdaWorkflowService {
|
|||||||
return task(task, planNo);
|
return task(task, planNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void ensureSignedTask(String customerId, InboundPlan plan, String operatorId, String operatorName,
|
||||||
|
List<Long> photoFileIds, LocalDateTime signedAt) {
|
||||||
|
InboundPdaTask task = repository.lockTask(customerId, plan.getId());
|
||||||
|
if (task == null) {
|
||||||
|
task = new InboundPdaTask();
|
||||||
|
task.setId(IdWorker.getId()); task.setPlanId(plan.getId()); task.setCustomerId(customerId);
|
||||||
|
task.setTaskNo("PDA" + task.getId()); task.setCreatedAt(signedAt); task.setVersion(0);
|
||||||
|
repository.insert(task);
|
||||||
|
}
|
||||||
|
if ("COMPLETED".equals(task.getStatus())) return;
|
||||||
|
task.setStatus("SIGNED"); task.setOperatorId(operatorId); task.setOperatorName(operatorName);
|
||||||
|
task.setUnloadPhotoFileIds(write(photoFileIds)); task.setUnloadPhotoUploadedAt(signedAt);
|
||||||
|
task.setEstimatedEmptyReturnAt(signedAt.plusDays(2)); task.setUpdatedAt(signedAt);
|
||||||
|
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
|
||||||
|
plan.setStatus("SIGNED"); plan.setUpdatedAt(signedAt); repository.update(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
|
||||||
|
WarehouseResourceGateway.WarehousePalletReference resource) {
|
||||||
|
if (pallet.getWarehouseResourceLockId() != null) {
|
||||||
|
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return;
|
||||||
|
throw conflict("Destination pallet is already bound to another physical pallet");
|
||||||
|
}
|
||||||
|
String destinationKey = destinationKey(pallet);
|
||||||
|
String businessId = plan.getContainerNo() + ":" + destinationKey;
|
||||||
|
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
|
||||||
|
long lockId = warehouse.reserve(customerId, resource.warehouseId(), resource.palletId(), resource.locationId(),
|
||||||
|
"INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey);
|
||||||
|
pallet.setWarehousePalletId(resource.palletId()); pallet.setWarehouseLocationId(resource.locationId());
|
||||||
|
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
|
||||||
|
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int receiveBulkResolved(String customerId, InboundPlan plan, InboundPlanLine line,
|
||||||
|
InboundPlanPallet pallet, int packageCount,
|
||||||
|
String operatorId, String operatorName) {
|
||||||
|
InboundPdaTask task = requireActiveTask(customerId, plan);
|
||||||
|
int received = repository.allocations(plan.getId()).stream()
|
||||||
|
.filter(value -> Objects.equals(value.getLineId(), line.getId()))
|
||||||
|
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
|
||||||
|
int pending = value(line.getRequiredPackageCount()) - received;
|
||||||
|
if (packageCount > pending) throw conflict("Received package count exceeds the inbound pending total");
|
||||||
|
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
|
||||||
|
if (allocation == null) {
|
||||||
|
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||||
|
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||||
|
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME);
|
||||||
|
allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0);
|
||||||
|
allocation.setPickedPackageCount(0); allocation.setActualVolumeCbm(ZERO_VOLUME);
|
||||||
|
allocation.setStatus("RECEIVING"); allocation.setVersion(0); repository.insert(allocation);
|
||||||
|
}
|
||||||
|
BigDecimal perPackage = perPackageVolume(line);
|
||||||
|
allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + packageCount);
|
||||||
|
allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + packageCount);
|
||||||
|
allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm())
|
||||||
|
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
|
||||||
|
allocation.setStatus(allocation.getPickedPackageCount() == value(line.getRequiredPackageCount())
|
||||||
|
? "RECEIVED" : "RECEIVING"); bump(allocation); repository.update(allocation);
|
||||||
|
pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + packageCount);
|
||||||
|
pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm())
|
||||||
|
.add(perPackage.multiply(BigDecimal.valueOf(packageCount))).setScale(4, RoundingMode.HALF_UP));
|
||||||
|
repository.update(pallet); startPicking(task, plan, operatorId, operatorName);
|
||||||
|
return packageCount;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
|
public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
|
||||||
PdaRequests.PackageOperation request) {
|
PdaRequests.PackageOperation request) {
|
||||||
@@ -395,14 +463,18 @@ public class InboundPdaWorkflowService {
|
|||||||
throw conflict("All line picked package totals must exactly equal required package totals");
|
throw conflict("All line picked package totals must exactly equal required package totals");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Map<Long, String> destinationTypes = lines.stream().collect(java.util.stream.Collectors.toMap(
|
||||||
|
InboundPlanLine::getId, line -> destinationType(line)));
|
||||||
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
|
List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
|
||||||
if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) {
|
if (cartons.stream().anyMatch(carton -> "OVERSEAS".equals(destinationTypes.get(carton.getLineId()))
|
||||||
|
&& !"ON_PALLET".equals(carton.getStatus()))) {
|
||||||
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
|
throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
|
||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
|
stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now);
|
||||||
for (InboundPlanCarton carton : cartons) {
|
for (InboundPlanCarton carton : cartons) {
|
||||||
carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now);
|
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
|
||||||
|
carton.setReceivedAt(now); carton.setUpdatedAt(now);
|
||||||
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton);
|
||||||
}
|
}
|
||||||
task.setStatus("COMPLETED");
|
task.setStatus("COMPLETED");
|
||||||
@@ -705,6 +777,13 @@ public class InboundPdaWorkflowService {
|
|||||||
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
|
return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String destinationType(InboundPlanLine line) {
|
||||||
|
if (line.getDestinationType() != null && !line.getDestinationType().isBlank()) {
|
||||||
|
return line.getDestinationType().trim().toUpperCase();
|
||||||
|
}
|
||||||
|
return line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank() ? "OVERSEAS" : "FBA";
|
||||||
|
}
|
||||||
|
|
||||||
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
|
private InboundPdaOperation replay(String customerId, String action, String key, Object request) {
|
||||||
InboundPdaOperation operation = repository.operation(customerId, key);
|
InboundPdaOperation operation = repository.operation(customerId, key);
|
||||||
if (operation == null) return null;
|
if (operation == null) return null;
|
||||||
|
|||||||
+469
@@ -0,0 +1,469 @@
|
|||||||
|
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.MapperFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReceivingContainerService {
|
||||||
|
@Resource private InboundPlanMapper plans;
|
||||||
|
@Resource private InboundPlanLineMapper lines;
|
||||||
|
@Resource private InboundPlanPalletMapper pallets;
|
||||||
|
@Resource private InboundPlanPalletAllocationMapper allocations;
|
||||||
|
@Resource private InboundPlanCartonMapper cartons;
|
||||||
|
@Resource private LastlegOrderMapper orders;
|
||||||
|
@Resource private ReceivingContainerSessionMapper sessions;
|
||||||
|
@Resource private ReceivingContainerOperationMapper operations;
|
||||||
|
@Resource private WarehouseResourceGateway warehouse;
|
||||||
|
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
||||||
|
@Resource private InboundPdaWorkflowService workflow;
|
||||||
|
@Resource private ObjectMapper sourceObjectMapper;
|
||||||
|
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initializeObjectMapper() {
|
||||||
|
objectMapper = sourceObjectMapper.copy()
|
||||||
|
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||||
|
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReceivingContainerResponses.Candidate> candidates(String customerId, String operatorKey) {
|
||||||
|
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
||||||
|
Map<String, List<InboundPlan>> grouped = new LinkedHashMap<>();
|
||||||
|
plans.selectList(new LambdaQueryWrapper<InboundPlan>().eq(InboundPlan::getCustomerId, customerId)
|
||||||
|
.ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> {
|
||||||
|
if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) {
|
||||||
|
grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return grouped.entrySet().stream().map(entry -> {
|
||||||
|
Totals totals = totals(entry.getValue());
|
||||||
|
boolean active = current != null && entry.getKey().equals(current.getContainerNo());
|
||||||
|
return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN",
|
||||||
|
entry.getValue().size(), totals.inboundCount(), totals.total(), totals.received(), active);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) {
|
||||||
|
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
||||||
|
ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey));
|
||||||
|
if (current != null && !containerNo.equals(current.getContainerNo()) && !"WAIT_SIGN".equals(current.getStatus())) {
|
||||||
|
throw conflict("Current account is locked to another signed container");
|
||||||
|
}
|
||||||
|
return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView(
|
||||||
|
customerId, operatorKey, containerNo), containerPlans);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName,
|
||||||
|
String containerNo, ReceivingContainerRequests.Action request) {
|
||||||
|
ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
|
List<InboundPlan> containerPlans = requirePlans(customerId, containerNo);
|
||||||
|
String activeOperatorKey = activeOperatorKey(customerId, operatorKey);
|
||||||
|
ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey);
|
||||||
|
if (active != null) {
|
||||||
|
if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one");
|
||||||
|
ReceivingContainerResponses.Session result = snapshot(active, containerPlans);
|
||||||
|
save(active, "PREPARE", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) {
|
||||||
|
throw conflict("Container is already signed or prepared by another account");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
ReceivingContainerSession session = new ReceivingContainerSession();
|
||||||
|
session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo);
|
||||||
|
session.setOperatorKey(operatorKey); session.setOperatorName(operatorName);
|
||||||
|
session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo));
|
||||||
|
session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now);
|
||||||
|
session.setVersion(0);
|
||||||
|
try { sessions.insert(session); }
|
||||||
|
catch (DuplicateKeyException duplicate) { throw conflict("Account or container already has an active receiving session"); }
|
||||||
|
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||||
|
save(session, "PREPARE", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId,
|
||||||
|
MultipartFile file) {
|
||||||
|
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||||
|
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||||
|
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||||
|
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName,
|
||||||
|
ReceivingContainerRequests.Sign request) {
|
||||||
|
ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
|
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||||
|
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||||
|
List<Long> photos = request.photoFileIds().stream().distinct().sorted().toList();
|
||||||
|
if (photos.size() != request.photoFileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated");
|
||||||
|
photos.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
session.setPhotoFileIds(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||||
|
session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session);
|
||||||
|
for (InboundPlan plan : requirePlans(customerId, session.getContainerNo())) {
|
||||||
|
workflow.ensureSignedTask(customerId, plan, operatorKey, operatorName, photos, now);
|
||||||
|
}
|
||||||
|
ReceivingContainerResponses.Session result = snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
||||||
|
save(session, "SIGN", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivingContainerResponses.Session current(String customerId, String operatorKey) {
|
||||||
|
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false);
|
||||||
|
return snapshot(session, requirePlans(customerId, session.getContainerNo()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey,
|
||||||
|
ReceivingContainerRequests.CartonResolve request) {
|
||||||
|
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||||
|
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||||
|
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||||
|
if (carton == null) throw notFound("Carton mark was not found");
|
||||||
|
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
|
||||||
|
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
|
||||||
|
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
|
||||||
|
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||||
|
throw conflict("FBA inbound must use bulk count instead of carton scan");
|
||||||
|
}
|
||||||
|
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||||
|
return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(),
|
||||||
|
inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(),
|
||||||
|
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
|
||||||
|
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey,
|
||||||
|
ReceivingContainerRequests.PalletResolve request) {
|
||||||
|
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||||
|
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||||
|
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode());
|
||||||
|
validatePallet(ref, pallet);
|
||||||
|
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
||||||
|
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName,
|
||||||
|
ReceivingContainerRequests.BulkReceive request) {
|
||||||
|
ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||||
|
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||||
|
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
|
||||||
|
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
|
||||||
|
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
||||||
|
}
|
||||||
|
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
|
||||||
|
validatePallet(ref, resource);
|
||||||
|
InboundPlanPallet pallet = choosePallet(ref, resource);
|
||||||
|
workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
|
||||||
|
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet,
|
||||||
|
request.packageCount(), operatorKey, operatorName);
|
||||||
|
markReceiving(session);
|
||||||
|
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||||
|
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||||
|
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted,
|
||||||
|
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
|
||||||
|
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName,
|
||||||
|
ReceivingContainerRequests.CartonReceive request) {
|
||||||
|
ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||||
|
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||||
|
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
|
||||||
|
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark()));
|
||||||
|
if (carton == null) throw notFound("Carton mark was not found");
|
||||||
|
InboundRef ref = refs(customerId, session.getContainerNo()).stream()
|
||||||
|
.filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst()
|
||||||
|
.orElseThrow(() -> conflict("Carton mark belongs to another container"));
|
||||||
|
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
|
||||||
|
throw conflict("FBA destination must use bulk count");
|
||||||
|
}
|
||||||
|
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
|
||||||
|
validatePallet(ref, resource);
|
||||||
|
InboundPlanPallet pallet = choosePallet(ref, resource);
|
||||||
|
workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
|
||||||
|
workflow.scanCarton(customerId, ref.plan().getPlanNo(), new PdaRequests.ScanCarton(
|
||||||
|
request.cartonMark(), pallet.getId(), request.idempotencyKey() + ":SCAN", operatorKey, operatorName));
|
||||||
|
markReceiving(session);
|
||||||
|
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||||
|
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||||
|
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1,
|
||||||
|
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus());
|
||||||
|
save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReceivingContainerResponses.Session complete(String customerId, String operatorKey,
|
||||||
|
ReceivingContainerRequests.Action request) {
|
||||||
|
ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||||
|
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||||
|
ReceivingContainerSession session = requireSigned(customerId, operatorKey);
|
||||||
|
List<InboundPlan> containerPlans = requirePlans(customerId, session.getContainerNo());
|
||||||
|
Totals totals = totals(containerPlans);
|
||||||
|
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
||||||
|
for (InboundPlan plan : containerPlans) workflow.complete(customerId, plan.getPlanNo());
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
|
||||||
|
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
|
||||||
|
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||||
|
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) {
|
||||||
|
String key = activeOperatorKey(customerId, operatorKey);
|
||||||
|
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key)
|
||||||
|
: sessions.findActiveByOperator(customerId, key);
|
||||||
|
if (session == null) throw notFound("Current receiving container session was not found");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerSession requireSigned(String customerId, String operatorKey) {
|
||||||
|
ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true);
|
||||||
|
if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) {
|
||||||
|
throw conflict("Sign the receiving container before receiving cargo");
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<InboundPlan> requirePlans(String customerId, String containerNo) {
|
||||||
|
List<InboundPlan> values = plansForContainer(customerId, containerNo);
|
||||||
|
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||||
|
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||||
|
throw conflict("Receiving container is already completed");
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<InboundPlan> plansForContainer(String customerId, String containerNo) {
|
||||||
|
List<InboundPlan> values = plans.selectList(new LambdaQueryWrapper<InboundPlan>()
|
||||||
|
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getContainerNo, containerNo)
|
||||||
|
.orderByAsc(InboundPlan::getId));
|
||||||
|
if (values.isEmpty()) throw notFound("Receiving container was not found");
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<InboundRef> refs(String customerId, String containerNo) {
|
||||||
|
List<InboundRef> result = new ArrayList<>();
|
||||||
|
for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
|
||||||
|
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||||
|
.eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) {
|
||||||
|
LastlegOrder order = orders.selectById(line.getOrderId());
|
||||||
|
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
|
||||||
|
result.add(new InboundRef(plan, line, order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InboundRef requireInbound(String customerId, String containerNo, String inboundCode) {
|
||||||
|
List<InboundRef> matches = refs(customerId, containerNo).stream()
|
||||||
|
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
|
||||||
|
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container");
|
||||||
|
if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container");
|
||||||
|
return matches.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) {
|
||||||
|
List<InboundPlanPallet> candidates = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||||
|
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||||
|
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream()
|
||||||
|
.filter(value -> sameDestination(ref.line(), value))
|
||||||
|
.filter(value -> value.getWarehousePalletId() == null || Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||||
|
.sorted(Comparator.comparing((InboundPlanPallet value) -> !Objects.equals(value.getWarehousePalletId(), resource.palletId()))
|
||||||
|
.thenComparing(value -> value.getActualPackageCount() == null ? 0 : value.getActualPackageCount()))
|
||||||
|
.toList();
|
||||||
|
if (candidates.isEmpty()) throw conflict("Pallet does not match an available destination pallet in this inbound code");
|
||||||
|
return candidates.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
|
||||||
|
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
|
||||||
|
long actualWarehouse;
|
||||||
|
try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); }
|
||||||
|
catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); }
|
||||||
|
if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse");
|
||||||
|
String type = destinationType(ref.line(), ref.order());
|
||||||
|
if ("FBA".equals(type)) {
|
||||||
|
if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank()
|
||||||
|
|| !ref.line().getFbaWarehouseCode().equalsIgnoreCase(String.valueOf(pallet.fbaWarehouseCode()))) {
|
||||||
|
throw conflict("Pallet FBA destination does not match the inbound code");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) {
|
||||||
|
throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton");
|
||||||
|
}
|
||||||
|
if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) {
|
||||||
|
try {
|
||||||
|
if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) {
|
||||||
|
throw conflict("Pallet destination warehouse does not match the inbound code");
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// Destination codes that are not resource IDs are still protected by the actual overseas warehouse check.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameDestination(InboundPlanLine line, InboundPlanPallet pallet) {
|
||||||
|
if (line.getDestinationWarehouseId() != null && !line.getDestinationWarehouseId().isBlank()) {
|
||||||
|
return line.getDestinationWarehouseId().equals(pallet.getDestinationWarehouseId());
|
||||||
|
}
|
||||||
|
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||||
|
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
|
||||||
|
.map(this::inbound).toList();
|
||||||
|
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
|
||||||
|
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
|
||||||
|
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(),
|
||||||
|
value(session.getVersion()), photoIds(session), session.getPreparedAt(), session.getSignedAt(),
|
||||||
|
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
|
||||||
|
int received = allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||||
|
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
|
||||||
|
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
|
||||||
|
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
|
||||||
|
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
|
||||||
|
int total = value(ref.line().getRequiredPackageCount());
|
||||||
|
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
|
||||||
|
ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(),
|
||||||
|
ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(),
|
||||||
|
value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Totals totals(List<InboundPlan> containerPlans) {
|
||||||
|
List<ReceivingContainerResponses.Inbound> values = refs(containerPlans.get(0).getCustomerId(),
|
||||||
|
containerPlans.get(0).getContainerNo()).stream().map(this::inbound).toList();
|
||||||
|
return new Totals(values.size(), values.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(),
|
||||||
|
values.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String destinationType(InboundPlanLine line, LastlegOrder order) {
|
||||||
|
String value = line.getDestinationType();
|
||||||
|
if (value == null || value.isBlank()) value = order.getDestinationType();
|
||||||
|
if (value == null || value.isBlank()) value = line.getFbaWarehouseCode() == null || line.getFbaWarehouseCode().isBlank()
|
||||||
|
? "OVERSEAS" : "FBA";
|
||||||
|
value = value.trim().toUpperCase();
|
||||||
|
if (!List.of("FBA", "OVERSEAS").contains(value)) throw conflict("Unsupported destination type");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markReceiving(ReceivingContainerSession session) {
|
||||||
|
if (!"RECEIVING".equals(session.getStatus())) {
|
||||||
|
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
session.setUpdatedAt(LocalDateTime.now()); update(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update(ReceivingContainerSession session) {
|
||||||
|
if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) {
|
||||||
|
ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId);
|
||||||
|
value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) {
|
||||||
|
ReceivingContainerOperation value = operations.find(customerId, operatorKey, key);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) {
|
||||||
|
throw conflict("Idempotency key was already used with a different request");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(ReceivingContainerSession session, String action, String key, Object request, Object result) {
|
||||||
|
ReceivingContainerOperation value = new ReceivingContainerOperation(); value.setId(IdWorker.getId());
|
||||||
|
value.setSessionId(session.getId()); value.setCustomerId(session.getCustomerId());
|
||||||
|
value.setOperatorKey(session.getOperatorKey()); value.setAction(action); value.setIdempotencyKey(key);
|
||||||
|
value.setRequestHash(hash(request)); value.setRequestJson(write(request)); value.setResultJson(write(result));
|
||||||
|
value.setCreatedAt(LocalDateTime.now()); operations.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> photoIds(ReceivingContainerSession session) {
|
||||||
|
if (session.getPhotoFileIds() == null || session.getPhotoFileIds().isBlank()) return List.of();
|
||||||
|
try { return objectMapper.readValue(session.getPhotoFileIds(), new TypeReference<List<Long>>() { }); }
|
||||||
|
catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read receiving photo IDs", exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hash(Object value) {
|
||||||
|
try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||||
|
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private String write(Object value) {
|
||||||
|
try { return objectMapper.writeValueAsString(value); }
|
||||||
|
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T read(ReceivingContainerOperation operation, Class<T> type) {
|
||||||
|
try { return objectMapper.readValue(operation.getResultJson(), type); }
|
||||||
|
catch (JsonProcessingException exception) { throw new IllegalStateException(exception); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; }
|
||||||
|
private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; }
|
||||||
|
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||||
|
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
|
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||||
|
|
||||||
|
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { }
|
||||||
|
private record Totals(int inboundCount, int total, int received) { }
|
||||||
|
}
|
||||||
+2
@@ -162,6 +162,8 @@ public class InboundPlanService {
|
|||||||
line.setPlanId(plan.getId());
|
line.setPlanId(plan.getId());
|
||||||
line.setOrderId(order.orderId());
|
line.setOrderId(order.orderId());
|
||||||
line.setInboundCode(order.inboundCode());
|
line.setInboundCode(order.inboundCode());
|
||||||
|
line.setDestinationType(order.fbaWarehouseCode() == null || order.fbaWarehouseCode().isBlank()
|
||||||
|
? "OVERSEAS" : "FBA");
|
||||||
line.setFbaWarehouseCode(order.fbaWarehouseCode());
|
line.setFbaWarehouseCode(order.fbaWarehouseCode());
|
||||||
line.setDestinationWarehouseId(order.destinationWarehouseId());
|
line.setDestinationWarehouseId(order.destinationWarehouseId());
|
||||||
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
|
line.setDestinationWarehouseCode(order.destinationWarehouseCode());
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ public class LastlegOrderService {
|
|||||||
|
|
||||||
private LastlegOrderSummary summary(LastlegOrder o) {
|
private LastlegOrderSummary summary(LastlegOrder o) {
|
||||||
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
|
return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(),
|
||||||
LastlegOrderType.labelOf(o.getOrderType()), o.getSourceInboundId(),
|
LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(),
|
||||||
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(),
|
||||||
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(),
|
||||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
|
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(),
|
||||||
|
|||||||
+58
-4
@@ -107,12 +107,27 @@ public class LoadingCompletedConsumer {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
String destinationType = destinationType(payload, order);
|
||||||
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
|
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
|
||||||
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
if ("OVERSEAS".equals(destinationType)) {
|
||||||
|
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||||
|
} else {
|
||||||
|
validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
||||||
|
Set<String> orderCartonMarks) {
|
||||||
|
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
|
||||||
|
if (skuCode == null || skuCode.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU");
|
||||||
|
}
|
||||||
|
if (marks.isEmpty()) return;
|
||||||
|
requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks);
|
||||||
|
}
|
||||||
|
|
||||||
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
||||||
Set<String> orderCartonMarks) {
|
Set<String> orderCartonMarks) {
|
||||||
if (skuCode == null || skuCode.isBlank()) {
|
if (skuCode == null || skuCode.isBlank()) {
|
||||||
@@ -139,6 +154,7 @@ public class LoadingCompletedConsumer {
|
|||||||
}
|
}
|
||||||
LastlegOrder order=new LastlegOrder();
|
LastlegOrder order=new LastlegOrder();
|
||||||
String orderType = source.orderType();
|
String orderType = source.orderType();
|
||||||
|
String destinationType = destinationType(payload, source);
|
||||||
String customerId = source.customerId() == null || source.customerId().isBlank()
|
String customerId = source.customerId() == null || source.customerId().isBlank()
|
||||||
? event.tenantId() : source.customerId();
|
? event.tenantId() : source.customerId();
|
||||||
if (!event.tenantId().equals(customerId)) {
|
if (!event.tenantId().equals(customerId)) {
|
||||||
@@ -150,6 +166,7 @@ public class LoadingCompletedConsumer {
|
|||||||
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
|
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
|
||||||
}
|
}
|
||||||
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
|
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
|
||||||
|
order.setDestinationType(destinationType);
|
||||||
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
||||||
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
||||||
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
||||||
@@ -186,7 +203,7 @@ public class LoadingCompletedConsumer {
|
|||||||
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
|
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
|
||||||
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
|
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
|
||||||
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
|
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
|
||||||
createCartonLabels(customerId, order.getId(), item);
|
createCartonLabels(customerId, order.getId(), destinationType, item);
|
||||||
}
|
}
|
||||||
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
|
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
|
||||||
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
||||||
@@ -216,6 +233,7 @@ public class LoadingCompletedConsumer {
|
|||||||
snapshot.setDestinationWarehouseId(source.destinationWarehouseId());
|
snapshot.setDestinationWarehouseId(source.destinationWarehouseId());
|
||||||
snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode());
|
snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode());
|
||||||
snapshot.setDestinationWarehouseName(source.destinationWarehouseName());
|
snapshot.setDestinationWarehouseName(source.destinationWarehouseName());
|
||||||
|
snapshot.setDestinationType(order.getDestinationType());
|
||||||
snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity());
|
snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity());
|
||||||
snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg());
|
snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg());
|
||||||
snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm());
|
snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm());
|
||||||
@@ -243,9 +261,11 @@ public class LoadingCompletedConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createCartonLabels(String customerId, Long orderId, FirstLegLoadingPlanCompletedV1.Item item) {
|
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
||||||
|
FirstLegLoadingPlanCompletedV1.Item item) {
|
||||||
|
if (!"OVERSEAS".equals(destinationType)) return;
|
||||||
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
|
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
|
||||||
if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。
|
if (marks.isEmpty()) return;
|
||||||
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
|
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
|
||||||
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
|
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
|
||||||
}
|
}
|
||||||
@@ -261,6 +281,40 @@ public class LoadingCompletedConsumer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String destinationType(FirstLegLoadingPlanCompletedV1 payload,
|
||||||
|
FirstLegLoadingPlanCompletedV1.Order order) {
|
||||||
|
String explicit = normalizeDestinationType(order.destinationType());
|
||||||
|
if (explicit != null) return explicit;
|
||||||
|
|
||||||
|
boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo())
|
||||||
|
|| hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName()));
|
||||||
|
boolean overseasWarehouseIdentity = hasText(payload.warehouseId())
|
||||||
|
&& Objects.equals(payload.warehouseId(), order.destinationWarehouseId());
|
||||||
|
if (fbaEvidence && overseasWarehouseIdentity) {
|
||||||
|
throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId());
|
||||||
|
}
|
||||||
|
if (fbaEvidence) return "FBA";
|
||||||
|
if (overseasWarehouseIdentity) return "OVERSEAS";
|
||||||
|
throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeDestinationType(String value) {
|
||||||
|
if (!hasText(value)) return null;
|
||||||
|
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||||
|
if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) {
|
||||||
|
throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean startsWithFba(String value) {
|
||||||
|
return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||||
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
||||||
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
|
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
|||||||
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
|
||||||
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
|
customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识',
|
||||||
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发',
|
order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发',
|
||||||
|
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码',
|
||||||
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
|
inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码',
|
||||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
|
source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统',
|
||||||
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
|
source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键',
|
||||||
@@ -141,6 +142,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_order (
|
|||||||
source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
|
source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID',
|
||||||
inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID',
|
inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID',
|
||||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称',
|
||||||
|
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码',
|
||||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数',
|
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数',
|
||||||
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)',
|
gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)',
|
||||||
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID',
|
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID',
|
||||||
@@ -171,6 +173,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等
|
|||||||
ALTER TABLE ll_order COMMENT='尾程订单主表';
|
ALTER TABLE ll_order COMMENT='尾程订单主表';
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||||
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
|
PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||||
|
PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name'));
|
||||||
|
PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt;
|
||||||
ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细';
|
ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细';
|
||||||
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
|
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
|
||||||
ALTER TABLE ll_event_conflict COMMENT='事件冲突记录';
|
ALTER TABLE ll_event_conflict COMMENT='事件冲突记录';
|
||||||
@@ -360,9 +366,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
|
CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
|
||||||
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
|
id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)',
|
||||||
|
customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||||
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
|
plan_id BIGINT NOT NULL COMMENT '入库计划ID',
|
||||||
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
|
order_id BIGINT NOT NULL COMMENT '尾程订单ID',
|
||||||
inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照',
|
inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照',
|
||||||
|
destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码',
|
||||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||||
@@ -381,7 +389,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
|||||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
||||||
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
||||||
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||||
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
||||||
@@ -623,9 +631,38 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge (
|
|||||||
|
|
||||||
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='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code'));
|
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='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code'));
|
||||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户账号标识''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||||
|
PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt;
|
||||||
|
UPDATE ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id SET l.customer_id=p.customer_id WHERE l.customer_id='';
|
||||||
|
ALTER TABLE ll_inbound_plan_line MODIFY COLUMN customer_id VARCHAR(128) NOT NULL 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='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code'));
|
||||||
|
PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt;
|
||||||
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
|
UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id
|
||||||
SET l.fba_warehouse_code=o.fba_warehouse_code
|
SET l.fba_warehouse_code=o.fba_warehouse_code,
|
||||||
WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL;
|
l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(o.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END)
|
||||||
|
WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL)
|
||||||
|
OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||||
|
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||||
|
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键',
|
||||||
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
|
photo_file_ids JSON NULL COMMENT '签收卸柜照片文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||||
|
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
|
||||||
|
completed_at DATETIME(3) 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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_active_operator(active_operator_key), UNIQUE KEY uk_ll_receiving_active_container(active_container_key),
|
||||||
|
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||||
|
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识',
|
||||||
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||||
|
request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
|
||||||
id BIGINT NOT NULL COMMENT '主键ID',
|
id BIGINT NOT NULL COMMENT '主键ID',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ paths:
|
|||||||
- { name: detained, in: query, schema: { type: boolean } }
|
- { name: detained, in: query, schema: { type: boolean } }
|
||||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||||
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } }
|
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } }
|
||||||
/api/platform/v1/last-leg/dispatches:
|
/api/platform/v1/last-leg/dispatches:
|
||||||
get:
|
get:
|
||||||
summary: 分页查询排车单
|
summary: 分页查询排车单
|
||||||
|
|||||||
+47
-3
@@ -4,18 +4,18 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
|
|||||||
class LoadingCompletedConsumerTest {
|
class LoadingCompletedConsumerTest {
|
||||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); }
|
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); }
|
||||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||||
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
||||||
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
|
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
|
||||||
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
|
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
|
||||||
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
|
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
|
||||||
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
|
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
|
||||||
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
|
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
|
||||||
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet));
|
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
|
||||||
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2);
|
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2);
|
||||||
|
|
||||||
assertEquals(1,service.consume(event));
|
assertEquals(1,service.consume(event));
|
||||||
@@ -31,6 +31,8 @@ class LoadingCompletedConsumerTest {
|
|||||||
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
|
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
|
||||||
EventEnvelope source=event();
|
EventEnvelope source=event();
|
||||||
JsonNode node=source.payload().deepCopy();
|
JsonNode node=source.payload().deepCopy();
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
|
||||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile");
|
||||||
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
|
||||||
.set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
|
.set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
|
||||||
@@ -51,6 +53,8 @@ class LoadingCompletedConsumerTest {
|
|||||||
}
|
}
|
||||||
@Test void rejectsV2EventWithoutSkuCartonMapping(){
|
@Test void rejectsV2EventWithoutSkuCartonMapping(){
|
||||||
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","OVERSEAS");
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","OVERSEAS");
|
||||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||||
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||||
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||||
@@ -59,6 +63,46 @@ class LoadingCompletedConsumerTest {
|
|||||||
assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数"));
|
assertTrue(error.getMessage().contains("箱唛数量必须等于实装箱数"));
|
||||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||||
}
|
}
|
||||||
|
@Test void legacyV2FbaWithCompleteFrozenCartonsPassesTheMappingGate(){
|
||||||
|
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0))
|
||||||
|
.set("cartonMarks",json.valueToTree(List.of("ABE3-BOX-1","ABE3-BOX-2","ABE3-BOX-3","ABE3-BOX-4","ABE3-BOX-5")));
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0))
|
||||||
|
.set("cartonMarks",json.valueToTree(List.of("LAX9-BOX-1","LAX9-BOX-2","LAX9-BOX-3")));
|
||||||
|
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||||
|
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||||
|
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||||
|
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||||
|
assertEquals(2,service.consume(v2));
|
||||||
|
verify(cartons,never()).insert(any());
|
||||||
|
}
|
||||||
|
@Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){
|
||||||
|
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0)).put("destinationType","FBA");
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1)).put("destinationType","FBA");
|
||||||
|
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||||
|
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),
|
||||||
|
source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),
|
||||||
|
2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||||
|
assertEquals(2,service.consume(v2));
|
||||||
|
verify(cartons,never()).insert(any());
|
||||||
|
}
|
||||||
|
@Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){
|
||||||
|
EventEnvelope source=event(); JsonNode node=source.payload().deepCopy();
|
||||||
|
for(JsonNode order:node.path("orders")){
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode)order).put("destinationWarehouseId","ow-1");
|
||||||
|
JsonNode item=order.path("items").get(0);
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode)item).putNull("fbaTrackingNo").putNull("shipmentNo").put("destinationWarehouseName","洛杉矶海外仓");
|
||||||
|
}
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5")));
|
||||||
|
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8")));
|
||||||
|
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||||
|
EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash);
|
||||||
|
assertEquals(2,service.consume(v2));
|
||||||
|
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture());
|
||||||
|
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||||
|
verify(cartons,times(8)).insert(any());
|
||||||
|
}
|
||||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () =>
|
|||||||
assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/)
|
assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/)
|
||||||
assert.match(controller, /EVENT_BRIDGE_REGION/)
|
assert.match(controller, /EVENT_BRIDGE_REGION/)
|
||||||
assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.EVENT_BRIDGE_REGION\}/)
|
assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{params\.EVENT_BRIDGE_REGION\}/)
|
||||||
|
assert.match(controller, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/)
|
||||||
|
assert.match(service, /choices: \['SELECT', 'CN', 'OVERSEAS'\]/)
|
||||||
|
assert.match(controller, /部署 event-bridge-service 必须明确选择 EVENT_BRIDGE_REGION=CN 或 OVERSEAS/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('service map freezes unique internal ports and moves event bridge away from 9300', async () => {
|
test('service map freezes unique internal ports and moves event bridge away from 9300', async () => {
|
||||||
@@ -72,6 +75,7 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as
|
|||||||
assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/)
|
assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/)
|
||||||
assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/)
|
assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/)
|
||||||
assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/)
|
assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/)
|
||||||
|
assert.match(scripts, /运行容器镜像与请求镜像不一致/)
|
||||||
assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/)
|
assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/)
|
||||||
assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/)
|
assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/)
|
||||||
for (const key of [
|
for (const key of [
|
||||||
|
|||||||
+9
@@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller;
|
|||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.warehouse.dto.DropshipPalletRequests;
|
import com.baiyue.warehouse.dto.DropshipPalletRequests;
|
||||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||||
|
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
|
||||||
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
||||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -22,4 +23,12 @@ public class InternalWarehousePalletController {
|
|||||||
tokenGuard.verify(token);
|
tokenGuard.verify(token);
|
||||||
return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId()));
|
return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/resolve/{palletCode}")
|
||||||
|
public ApiResponse<WarehousePalletResolveResponse> resolve(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@PathVariable String palletCode) {
|
||||||
|
tokenGuard.verify(token);
|
||||||
|
return ApiResponse.success(service.resolvePublicPallet(palletCode));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.baiyue.warehouse.dto;
|
||||||
|
|
||||||
|
public record WarehousePalletResolveResponse(
|
||||||
|
long palletId,
|
||||||
|
String palletCode,
|
||||||
|
long warehouseId,
|
||||||
|
long locationId,
|
||||||
|
String status,
|
||||||
|
String fbaWarehouseCode,
|
||||||
|
int version
|
||||||
|
) {
|
||||||
|
}
|
||||||
+9
@@ -17,6 +17,8 @@ import org.springframework.stereotype.Repository;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class WarehouseResourceRepository {
|
public class WarehouseResourceRepository {
|
||||||
@Resource private WarehouseMapper warehouses;
|
@Resource private WarehouseMapper warehouses;
|
||||||
@@ -135,6 +137,13 @@ public class WarehouseResourceRepository {
|
|||||||
.eq(WarehousePalletEntity::getId, id));
|
.eq(WarehousePalletEntity::getId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<WarehousePalletEntity> findPublicPalletsByCode(String palletCode) {
|
||||||
|
return pallets.selectList(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||||
|
.eq(WarehousePalletEntity::getCustomerId, 0L)
|
||||||
|
.eq(WarehousePalletEntity::getPalletCode, palletCode)
|
||||||
|
.orderByAsc(WarehousePalletEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) {
|
public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) {
|
||||||
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
|
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||||
.eq(WarehousePalletEntity::getCustomerId, customerId)
|
.eq(WarehousePalletEntity::getCustomerId, customerId)
|
||||||
|
|||||||
+16
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.warehouse.dto.LocationRequests;
|
import com.baiyue.warehouse.dto.LocationRequests;
|
||||||
import com.baiyue.warehouse.dto.PalletRequests;
|
import com.baiyue.warehouse.dto.PalletRequests;
|
||||||
|
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
|
||||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||||
@@ -175,6 +176,21 @@ public class WarehouseResourceService {
|
|||||||
return requirePallet(customerId, warehouseId, id);
|
return requirePallet(customerId, warehouseId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public WarehousePalletResolveResponse resolvePublicPallet(String palletCode) {
|
||||||
|
if (!StringUtils.hasText(palletCode)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "托盘码不能为空");
|
||||||
|
}
|
||||||
|
var matches = repository.findPublicPalletsByCode(palletCode.trim());
|
||||||
|
if (matches.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "托盘码不存在");
|
||||||
|
if (matches.size() != 1) throw new BizException(ResultCode.CONFLICT, "托盘码在多个仓库重复,无法解析");
|
||||||
|
WarehousePalletEntity pallet = matches.get(0);
|
||||||
|
if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位");
|
||||||
|
if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废");
|
||||||
|
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
|
||||||
|
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(),
|
||||||
|
pallet.getVersion() == null ? 0 : pallet.getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
|
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
|
||||||
PalletRequests.QueryRequest query) {
|
PalletRequests.QueryRequest query) {
|
||||||
requireWarehouse(customerId, warehouseId);
|
requireWarehouse(customerId, warehouseId);
|
||||||
|
|||||||
Reference in New Issue
Block a user