Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/sql/海外区域统一增量-MySQL8.sql
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java
#	lastleg-service/src/main/resources/db/lastleg.sql
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java
This commit is contained in:
2026-09-09 00:41:47 +08:00
15 changed files with 626 additions and 271 deletions
+7 -30
View File
@@ -1153,7 +1153,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态',
sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id), UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id),
KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code) KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code)
@@ -1440,7 +1440,7 @@ BEGIN
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick', 'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
'll_delivery_order','ll_delivery_fee','ll_delivery_operation', 'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger', 'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN 'll_exception_order','ll_customer_visit')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
END IF; END IF;
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
@@ -1556,7 +1556,7 @@ BEGIN
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick', 'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
'll_delivery_order','ll_delivery_fee','ll_delivery_operation', 'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger', 'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN 'll_exception_order','ll_customer_visit')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署';
END IF; END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
@@ -1818,31 +1818,6 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
USE baiyue_lastleg;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json'),
'SELECT 1',
'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)'''));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT ''原入仓单号快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT ''原进仓编码快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''原海外中转仓ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT ''原海外中转仓编码快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''原海外中转仓名称快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。 -- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。
USE baiyue_lastleg; USE baiyue_lastleg;
@@ -2020,12 +1995,14 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要',
snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) 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 '更新时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码'; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码';
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
@@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand {
private Long warehouseId; private Long warehouseId;
private String batchKey; private String batchKey;
private String status; private String status;
private String leaseToken;
private String preparationJson; private String preparationJson;
private String resultJson; private String resultJson;
private String lastError; private String lastError;
@@ -17,17 +17,33 @@ public interface ContainerPlanConversionCommandMapper extends BaseMapper<Contain
@Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1") @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} LIMIT 1")
ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId); ContainerPlanConversionCommand findByBooking(@Param("bookingId") String bookingId);
@Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}") @Update("UPDATE ll_container_plan_conversion_command SET status=#{status},result_json=COALESCE(#{resultJson},result_json),last_error=#{lastError},updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus} AND lease_token=#{leaseToken}")
int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus, int updateState(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("leaseToken") String leaseToken,
@Param("status") String status, @Param("status") String status,
@Param("resultJson") String resultJson, @Param("lastError") String lastError, @Param("resultJson") String resultJson, @Param("lastError") String lastError,
@Param("updatedAt") LocalDateTime updatedAt); @Param("updatedAt") LocalDateTime updatedAt);
@Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}") @Update("UPDATE ll_container_plan_conversion_command SET status=#{nextStatus},lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status=#{expectedStatus}")
int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus, int claim(@Param("id") long id, @Param("expectedStatus") String expectedStatus,
@Param("nextStatus") String nextStatus, @Param("updatedAt") LocalDateTime updatedAt); @Param("nextStatus") String nextStatus, @Param("leaseToken") String leaseToken,
@Param("updatedAt") LocalDateTime updatedAt);
@Update("UPDATE ll_container_plan_conversion_command SET last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}") @Update("UPDATE ll_container_plan_conversion_command SET lease_token=#{leaseToken},last_error=NULL,updated_at=#{updatedAt} WHERE id=#{id} AND status='APPLYING' AND updated_at=#{expectedUpdatedAt}")
int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt, int reclaimStale(@Param("id") long id, @Param("expectedUpdatedAt") LocalDateTime expectedUpdatedAt,
@Param("updatedAt") LocalDateTime updatedAt); @Param("leaseToken") String leaseToken, @Param("updatedAt") LocalDateTime updatedAt);
@Update("""
UPDATE ll_container_plan_conversion_command
SET idempotency_key=#{key},request_hash=#{requestHash},snapshot_version=#{snapshotVersion},
snapshot_hash=#{snapshotHash},warehouse_id=#{warehouseId},batch_key=#{batchKey},
status='PREPARED',lease_token=NULL,preparation_json=#{preparationJson},result_json=NULL,last_error=NULL,
updated_at=#{updatedAt}
WHERE id=#{id} AND status='FAILED'
""")
int replaceFailed(@Param("id") long id, @Param("key") String key,
@Param("requestHash") String requestHash, @Param("snapshotVersion") long snapshotVersion,
@Param("snapshotHash") String snapshotHash, @Param("warehouseId") long warehouseId,
@Param("batchKey") String batchKey, @Param("preparationJson") String preparationJson,
@Param("updatedAt") LocalDateTime updatedAt);
} }
@@ -117,36 +117,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
@Override @Override
public WarehousePalletReference resolvePallet(String palletCode) { public WarehousePalletReference resolvePallet(String palletCode) {
WarehousePalletData data = palletData(palletCode); WarehousePalletResponse response = lookupPallet(palletCode);
requireReservable(data); requireReservable(response.data());
return pallet(data); return reference(response.data());
} }
@Override @Override
public WarehousePalletReference inspectPallet(String palletCode) { public WarehousePalletReference inspectPallet(String palletCode) {
return pallet(palletData(palletCode)); return reference(lookupPallet(palletCode).data());
} }
private WarehousePalletData palletData(String palletCode) { @Override
WarehousePalletResponse response = client.get() public WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId,
.uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) long palletId, long locationId, long resourceLockId) {
.header("X-Internal-Service-Token", internalToken) if (customerId == null || customerId.isBlank() || warehouseId <= 0 || palletId <= 0
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { || locationId <= 0 || resourceLockId <= 0) {
throw palletLookupFailure(result.getStatusCode()); throw new ResponseStatusException(HttpStatus.CONFLICT,
}).body(WarehousePalletResponse.class); "Reserved pallet binding is incomplete");
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
} }
if (response == null || response.data() == null) { try {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, Long.parseLong(customerId);
"Warehouse pallet service returned no usable response"); } catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Reserved pallet customer binding is invalid");
} }
return response.data(); WarehousePalletData pallet = lookupPallet(palletCode).data();
} if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|| !"OCCUPIED".equals(pallet.status())) {
private WarehousePalletReference pallet(WarehousePalletData data) { throw new ResponseStatusException(HttpStatus.CONFLICT,
return new WarehousePalletReference(data.id(), data.palletCode(), data.warehouseId(), data.locationId(), "Scanned pallet does not match the reserved inbound plan resource");
data.status(), data.fbaWarehouseCode(), data.unitPrice(), data.version()); }
return reference(pallet);
} }
@Override @Override
@@ -169,6 +170,28 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed");
} }
private WarehousePalletResponse lookupPallet(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 palletLookupFailure(result.getStatusCode());
}).body(WarehousePalletResponse.class);
if (response != null && "A0404".equals(response.code())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found");
}
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
}
return response;
}
private WarehousePalletReference reference(WarehousePalletData pallet) {
return new WarehousePalletReference(pallet.id(), pallet.palletCode(), pallet.warehouseId(),
pallet.locationId(), pallet.status(), pallet.fbaWarehouseCode(), pallet.unitPrice(), pallet.version());
}
private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) { private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) {
HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT
: status.value() == 404 ? HttpStatus.NOT_FOUND : status.value() == 404 ? HttpStatus.NOT_FOUND
@@ -14,6 +14,10 @@ public interface WarehouseResourceGateway {
default WarehousePalletReference inspectPallet(String palletCode) { default WarehousePalletReference inspectPallet(String palletCode) {
return resolvePallet(palletCode); return resolvePallet(palletCode);
} }
default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId,
long palletId, long locationId, long resourceLockId) {
throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured");
}
default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured"); throw new UnsupportedOperationException("Warehouse location resolution is not configured");
} }
@@ -113,9 +113,8 @@ public class InboundPdaWorkflowService {
InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId);
validateDestinationWarehouse(pallet, request.warehouseId()); validateDestinationWarehouse(pallet, request.warehouseId());
if (pallet.getWarehouseResourceLockId() != null) { if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) confirmReservedAssignment(plan, pallet, request.palletId(), request.locationId(), null);
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
} }
String destinationKey = destinationKey(pallet); String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey; String businessId = plan.getContainerNo() + ":" + destinationKey;
@@ -130,12 +129,7 @@ public class InboundPdaWorkflowService {
pallet.setAssignmentStatus("ASSIGNED"); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); pallet.setLockedAt(LocalDateTime.now());
repository.update(pallet); repository.update(pallet);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId()); promotePlanWhenAllPalletsAssigned(plan);
if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) {
plan.setStatus("ASSIGNED");
plan.setUpdatedAt(LocalDateTime.now());
repository.update(plan);
}
} }
@Transactional @Transactional
@@ -167,13 +161,13 @@ public class InboundPdaWorkflowService {
@Transactional @Transactional
public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { WarehouseResourceGateway.WarehousePalletReference resource) {
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null;
throw conflict("Destination pallet is already bound to another physical pallet");
}
if (resource.palletNo() == null || resource.palletNo().isBlank()) { if (resource.palletNo() == null || resource.palletNo().isBlank()) {
throw conflict("Resolved physical pallet has no pallet code"); throw conflict("Resolved physical pallet has no pallet code");
} }
if (pallet.getWarehouseResourceLockId() != null) {
confirmReservedAssignment(plan, pallet, resource.palletId(), resource.locationId(), resource.palletNo());
return null;
}
String destinationKey = destinationKey(pallet); String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey; String businessId = plan.getContainerNo() + ":" + destinationKey;
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
@@ -184,6 +178,7 @@ public class InboundPdaWorkflowService {
pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
promotePlanWhenAllPalletsAssigned(plan);
return lockId; return lockId;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
compensateReservation(customerId, lockId, failure); compensateReservation(customerId, lockId, failure);
@@ -878,6 +873,35 @@ public class InboundPdaWorkflowService {
|| !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus()));
} }
private void confirmReservedAssignment(InboundPlan plan, InboundPlanPallet pallet,
long palletId, long locationId, String palletNo) {
boolean sameReservedResource = pallet.getWarehouseResourceLockId() != null
&& pallet.getWarehouseResourceLockId() > 0
&& Objects.equals(pallet.getWarehousePalletId(), palletId)
&& Objects.equals(pallet.getWarehouseLocationId(), locationId);
if (!sameReservedResource) {
throw conflict("Planned pallet is already assigned to another physical pallet or location");
}
if ("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())) return;
if (!"RESERVED".equals(pallet.getAssignmentStatus())) {
throw conflict("Reserved physical pallet has an invalid assignment status");
}
if (palletNo != null && !palletNo.isBlank()) pallet.setPalletNo(palletNo.trim());
pallet.setAssignmentStatus("ASSIGNED");
if (pallet.getLockedAt() == null) pallet.setLockedAt(LocalDateTime.now());
repository.update(pallet);
promotePlanWhenAllPalletsAssigned(plan);
}
private void promotePlanWhenAllPalletsAssigned(InboundPlan plan) {
List<InboundPlanPallet> plannedPallets = repository.pallets(plan.getId());
if (plannedPallets.isEmpty() || plannedPallets.stream().anyMatch(this::isNotAssigned)) return;
if (!java.util.Set.of("PENDING_ASSIGNMENT", "RESERVED").contains(plan.getStatus())) return;
plan.setStatus("ASSIGNED");
plan.setUpdatedAt(LocalDateTime.now());
repository.update(plan);
}
private InboundPlan requirePlan(String customerId, String planNo) { private InboundPlan requirePlan(String customerId, String planNo) {
InboundPlan plan = repository.lockPlan(customerId, planNo); InboundPlan plan = repository.lockPlan(customerId, planNo);
if (plan == null) throw notFound("Inbound plan not found"); if (plan == null) throw notFound("Inbound plan not found");
@@ -274,14 +274,8 @@ public class ReceivingContainerService {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
WarehouseResourceGateway.WarehousePalletReference pallet; PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
try { WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
pallet = warehouse.resolvePallet(request.palletCode());
} catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found");
}
if (pallet == null) throw notFound("Pallet was not found");
validatePallet(ref, pallet);
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
} }
@@ -301,9 +295,9 @@ public class ReceivingContainerService {
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages())); allocation.ref().line(), allocation.packages()));
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
validatePallet(ref, resource); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = choosePallet(ref, resource); InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try { try {
int accepted = 0; int accepted = 0;
@@ -344,9 +338,9 @@ public class ReceivingContainerService {
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count"); throw conflict("FBA destination must use bulk count");
} }
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.inspectPallet(request.palletCode()); PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
validatePallet(ref, resource); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = choosePallet(ref, resource); InboundPlanPallet pallet = selection.pallet();
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try { try {
@@ -639,6 +633,47 @@ public class ReceivingContainerService {
return candidates.get(0); return candidates.get(0);
} }
private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) {
List<InboundPlanPallet> reserved = 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 -> palletCode.trim().equals(value.getPalletNo()))
.filter(this::hasConfirmedReservation)
.toList();
if (reserved.size() > 1) {
throw conflict("Pallet code matches multiple reserved resources in this inbound plan");
}
WarehouseResourceGateway.WarehousePalletReference resource;
InboundPlanPallet pallet;
if (reserved.size() == 1) {
pallet = reserved.get(0);
long warehouseId = ref.plan().getOverseasWarehouseId() == null
? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId();
resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId,
pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
} else {
try {
resource = warehouse.resolvePallet(palletCode.trim());
} catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found");
}
if (resource == null) throw notFound("Pallet was not found");
pallet = requirePlanPallet ? choosePallet(ref, resource) : null;
}
validatePallet(ref, resource);
return new PalletSelection(pallet, resource);
}
private boolean hasConfirmedReservation(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0
&& pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0
&& pallet.getWarehouseResourceLockId() != null && pallet.getWarehouseResourceLockId() > 0
&& ("RESERVED".equals(pallet.getAssignmentStatus())
|| "ASSIGNED".equals(pallet.getAssignmentStatus())
|| "LOCKED".equals(pallet.getAssignmentStatus()));
}
private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) {
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
long actualWarehouse; long actualWarehouse;
@@ -666,6 +701,9 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
} }
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
.map(this::inbound).toList(); .map(this::inbound).toList();
@@ -46,82 +46,108 @@ public class ContainerInboundPlanConversionCoordinator {
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空"); String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符"); String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash())); String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash()));
ContainerPlanConversionCommand resolved = commands.find(bookingId, key); ContainerPlanConversionCommand direct = commands.find(bookingId, key);
if (resolved == null) resolved = commands.findByBooking(bookingId); ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct;
if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash); if (resolved == null) {
resolved = createCommand(bookingId, key, request, requestHash);
} else if ("COMPLETED".equals(resolved.getStatus())) {
if (direct != null) requireSameRequest(resolved, requestHash);
return result(resolved);
} else if ("FAILED".equals(resolved.getStatus())) {
resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash);
} else {
requireSameRequest(resolved, requestHash);
}
if ("COMPLETED".equals(resolved.getStatus())) {
requireSameRequest(resolved, requestHash);
return result(resolved);
}
final ContainerPlanConversionCommand command = resolved; final ContainerPlanConversionCommand command = resolved;
requireSameRequest(command, requestHash);
Preparation preparation = read(command.getPreparationJson(), Preparation.class); Preparation preparation = read(command.getPreparationJson(), Preparation.class);
if ("COMPLETED".equals(command.getStatus())) return result(command); boolean applyingLeaseOwned = false;
if ("APPLYING".equals(command.getStatus())) { if ("APPLYING".equals(command.getStatus())) {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String leaseToken = IdWorker.getIdStr();
if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now) if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now)
|| commands.reclaimStale(command.getId(), command.getUpdatedAt(), now) != 1) { || commands.reclaimStale(command.getId(), command.getUpdatedAt(), leaseToken, now) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
} }
command.setLeaseToken(leaseToken);
command.setUpdatedAt(now); command.setUpdatedAt(now);
applyingLeaseOwned = true;
} }
if (!"LOCAL_COMMITTED".equals(command.getStatus())) { if ("PREPARED".equals(command.getStatus())) {
if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) { String leaseToken = IdWorker.getIdStr();
if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) {
ContainerPlanConversionCommand current = commands.findByBooking(bookingId); ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
} }
command.setStatus("APPLYING"); command.setStatus("APPLYING");
command.setLeaseToken(leaseToken);
applyingLeaseOwned = true;
}
if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效");
} }
if (preparation.items().isEmpty()) { if (preparation.items().isEmpty()) {
try { try {
List<InboundPlanResponse> existing = inTransaction(() -> { List<InboundPlanResponse> existing = inTransaction(() -> {
List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId, List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), List.of()); request.snapshotVersion(), request.snapshotHash(), List.of());
state(command.getId(), "APPLYING", "COMPLETED", write(converted), null); state(command, "APPLYING", "COMPLETED", write(converted), null);
return converted; return converted;
}); });
command.setStatus("COMPLETED"); command.setStatus("COMPLETED");
return existing; return existing;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
resetForRetry(command, failure); ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
failWithoutResources(command, failure);
throw failure; throw failure;
} }
} }
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(),
item.planNo(), item.globalSequenceNo(), "STANDARD", item.fbaWarehouseCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
reservation = warehouse.ensurePalletBatch(ensure);
requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED");
} catch (RuntimeException failure) {
resetForRetry(command, failure);
throw failure;
}
List<InboundPlanResponse> localResult; List<InboundPlanResponse> localResult;
if ("LOCAL_COMMITTED".equals(command.getStatus())) { if ("LOCAL_COMMITTED".equals(command.getStatus())) {
localResult = result(command); localResult = result(command);
} else { } else {
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
item.fbaWarehouseCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
reservation = warehouse.ensurePalletBatch(ensure);
requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED");
} catch (RuntimeException failure) {
compensateAndFail(command, preparation, failure);
throw failure;
}
try { try {
List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation); List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation);
localResult = inTransaction(() -> { localResult = inTransaction(() -> {
List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId, List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), preparedPlans); request.snapshotVersion(), request.snapshotHash(), preparedPlans);
state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null); state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null);
return created; return created;
}); });
command.setStatus("LOCAL_COMMITTED"); command.setStatus("LOCAL_COMMITTED");
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
try { ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation( if (current != null && "COMPLETED".equals(current.getStatus())) {
preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(), return result(current);
"Lastleg local transaction did not commit")); }
} catch (RuntimeException compensationFailure) { if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) {
failure.addSuppressed(compensationFailure); command.setStatus(current.getStatus());
command.setResultJson(current.getResultJson());
localResult = result(current);
} else {
compensateAndFail(command, preparation, failure);
throw failure;
} }
resetForRetry(command, failure);
throw failure;
} }
} }
@@ -136,6 +162,35 @@ public class ContainerInboundPlanConversionCoordinator {
private ContainerPlanConversionCommand createCommand(String bookingId, String key, private ContainerPlanConversionCommand createCommand(String bookingId, String key,
ConvertContainerInboundPlanRequest request, ConvertContainerInboundPlanRequest request,
String requestHash) { String requestHash) {
long commandId = IdWorker.getId();
ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash);
try { inTransaction(() -> { commands.insert(command); return null; }); }
catch (DuplicateKeyException concurrent) {
ContainerPlanConversionCommand existing = commands.findByBooking(bookingId);
if (existing == null) throw concurrent;
return existing;
}
return command;
}
private ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous,
String bookingId, String key,
ConvertContainerInboundPlanRequest request,
String requestHash) {
ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(),
bookingId, key, request, requestHash);
int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(),
request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(),
replacement.getPreparationJson(), replacement.getUpdatedAt());
if (updated != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交");
}
return replacement;
}
private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId,
String key, ConvertContainerInboundPlanRequest request,
String requestHash) {
ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId);
if (preview.snapshotVersion() != request.snapshotVersion() if (preview.snapshotVersion() != request.snapshotVersion()
|| !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) { || !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) {
@@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator {
catch (RuntimeException exception) { catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
} }
long commandId = IdWorker.getId();
List<PreparedItem> items = new ArrayList<>(); List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0; int globalSequence = 0;
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream() for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
@@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator {
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence, localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
"FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null)); "FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null));
} }
} }
@@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator {
command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key); command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key);
command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion()); command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion());
command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId); command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId);
command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED"); command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED");
command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now()); command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now());
command.setUpdatedAt(command.getCreatedAt()); command.setUpdatedAt(command.getCreatedAt());
try { inTransaction(() -> { commands.insert(command); return null; }); }
catch (DuplicateKeyException concurrent) {
ContainerPlanConversionCommand existing = commands.findByBooking(bookingId);
if (existing == null) throw concurrent;
return existing;
}
return command; return command;
} }
@@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator {
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size, private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
String... acceptedStatuses) { String... acceptedStatuses) {
if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
|| !Objects.equals(batchKey, response.batchKey()) || response.items() == null
|| response.items().size() != size || java.util.Arrays.stream(acceptedStatuses) || response.items().size() != size || java.util.Arrays.stream(acceptedStatuses)
.noneMatch(status -> status.equals(response.status()))) { .noneMatch(status -> status.equals(response.status()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致"); throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致");
} }
String expectedItemStatus = switch (response.status()) {
case "RESERVED" -> "RESERVED";
case "CONFIRMED" -> "CONFIRMED";
case "COMPENSATED" -> "RELEASED";
default -> null;
};
boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item ->
item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0
|| item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null
|| trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null
|| item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status()));
if (invalidItem) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致");
}
} }
private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) { private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) {
@@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator {
} }
private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) { private void complete(ContainerPlanConversionCommand command, List<InboundPlanResponse> result) {
inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; });
command.setStatus("COMPLETED"); command.setStatus("COMPLETED");
} }
private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) { private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) {
if ("APPLYING".equals(command.getStatus())) { if ("APPLYING".equals(command.getStatus())) {
state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure)); state(command, "APPLYING", "FAILED", null, safeMessage(failure));
command.setStatus("PREPARED"); command.setStatus("FAILED");
} }
} }
private void state(long id, String expectedStatus, String status, String resultJson, String lastError) { private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation,
if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) { RuntimeException failure) {
try {
WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch(
new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(),
command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(),
"Lastleg local transaction did not commit"));
requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED");
failWithoutResources(command, failure);
} catch (ResponseStatusException compensationFailure) {
if (compensationFailure.getStatusCode().value() == 404) {
failWithoutResources(command, failure);
return;
}
failure.addSuppressed(compensationFailure);
} catch (RuntimeException compensationFailure) {
failure.addSuppressed(compensationFailure);
}
}
private void state(ContainerPlanConversionCommand command, String expectedStatus, String status,
String resultJson, String lastError) {
if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status,
resultJson, lastError, LocalDateTime.now()) != 1) {
throw new IllegalStateException("无法更新柜级转换命令状态"); throw new IllegalStateException("无法更新柜级转换命令状态");
} }
} }
@@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator {
String message = value.getMessage(); String message = value.getMessage();
return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length())); return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length()));
} }
private static String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record Preparation(long warehouseId, List<PreparedItem> items) { } public record Preparation(long warehouseId, List<PreparedItem> items) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo, public record PreparedItem(String customerId, long orderId, long planId, String planNo,
@@ -198,7 +198,8 @@ public class InboundPlanService {
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0); long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0);
String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders)); String snapshotHash = sha256(java.util.Arrays.asList(
bookingId, containerNo, warehouseId, snapshotVersion, orders));
return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode, return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked, warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
pendingOrders, pendingPallets, existingPallets, pendingOrders, pendingPallets, existingPallets,
@@ -300,8 +300,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''派送单乐观锁版本'' AFTER created_by'));
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
CREATE TABLE IF NOT EXISTS ll_delivery_fee ( CREATE TABLE IF NOT EXISTS ll_delivery_fee (
id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种',
@@ -589,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID',
batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键',
status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED',
lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离',
preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照',
result_json JSON NULL COMMENT '本地计划提交结果', result_json JSON NULL COMMENT '本地计划提交结果',
last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误', last_error VARCHAR(1000) NULL COMMENT '最近一次跨服务错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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 '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间',
PRIMARY KEY(id), PRIMARY KEY(id),
UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_command(source_container_booking_id),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key),
KEY idx_ll_container_conversion_status(status,updated_at) KEY idx_ll_container_conversion_status(status,updated_at)
) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status'));
PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt;
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)',
@@ -1396,7 +1397,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no 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_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id),
UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id),
KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code)
@@ -1590,23 +1591,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址',
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID', destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
destination_fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)',
operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
source_inventory_balance_id BIGINT NOT NULL COMMENT '原库存余额ID',
source_inventory_position_id BIGINT NOT NULL COMMENT '原库存库位ID',
source_inbound_order_id BIGINT NOT NULL COMMENT '原入仓单ID',
source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照',
source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (customer_id,application_id), KEY idx_ll_relabel_item_application (customer_id,application_id),
KEY idx_ll_relabel_item_sku (customer_id,sku_code), KEY idx_ll_relabel_item_sku (customer_id,sku_code),
KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id) KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细'; ) ENGINE=InnoDB COMMENT='换标货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -1990,17 +1980,6 @@ CREATE TABLE IF NOT EXISTS ll_exception_order_command (
KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at)
) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; ) ENGINE=InnoDB COMMENT='异常处理命令幂等审计';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='handling_result'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN handling_result VARCHAR(2000) NULL COMMENT ''异常处理结果'' AFTER handling_action'));
PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='evidence_file_ids_json'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN evidence_file_ids_json JSON NULL COMMENT ''异常处理证据文件ID列表'' AFTER handling_result'));
PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='inventory_adjustment_id'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN inventory_adjustment_id BIGINT NULL COMMENT ''退回库存恢复操作ID'' AFTER evidence_file_ids_json'));
PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='completed_at'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''异常处理完成时间'' AFTER exception_at'));
PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''异常订单乐观锁版本'' AFTER completed_at'));
PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt;
CREATE TABLE IF NOT EXISTS ll_customer_visit ( CREATE TABLE IF NOT EXISTS ll_customer_visit (
id BIGINT NOT NULL COMMENT '客户回访记录ID', id BIGINT NOT NULL COMMENT '客户回访记录ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
@@ -2061,25 +2040,6 @@ CREATE TABLE IF NOT EXISTS ll_security_projection (
KEY idx_security_projection_permission (aggregate_id,permission_version) KEY idx_security_projection_permission (aggregate_id,permission_version)
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
-- 兼容已有的旧版投影表:CREATE TABLE IF NOT EXISTS 不会补充后续新增字段。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version'));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1',
'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json'));
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL;
UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL;
ALTER TABLE ll_security_projection
MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照',
MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照';
-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 -- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
INSERT IGNORE INTO ll_security_projection ( INSERT IGNORE INTO ll_security_projection (
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version,
@@ -2151,91 +2111,3 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id),
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
CREATE TABLE IF NOT EXISTS ll_return_order_item (
id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt (
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku');
PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku;
EXECUTE ll_return_item_drop_tracking_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt;
SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking');
PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking;
EXECUTE ll_return_item_drop_tracking_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt;
SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order');
PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order;
EXECUTE ll_return_item_drop_order_stmt;
DEALLOCATE PREPARE ll_return_item_drop_order_stmt;
SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no');
PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no;
EXECUTE ll_return_item_drop_tracking_no_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt;
SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku');
PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku;
EXECUTE ll_return_item_add_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt;
SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku');
PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx;
EXECUTE ll_return_item_add_sku_idx_stmt;
DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt;
UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound');
PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check;
EXECUTE ll_return_receipt_drop_bound_check_stmt;
DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt;
SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))';
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh'));
PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt;
-- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。
UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL;
ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减';
CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
@@ -66,6 +66,22 @@ class HttpWarehouseResourceGatewayTest {
server.verify(); server.verify();
} }
@Test
void resolveReservedAcceptsOnlyExactOccupiedPlanBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED",
"warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L);
assertThat(result.palletId()).isEqualTo(31L);
assertThat(result.status()).isEqualTo("OCCUPIED");
server.verify();
}
@Test @Test
void inspectAllowsOccupiedPalletForReadOnlyInventoryQueries() { void inspectAllowsOccupiedPalletForReadOnlyInventoryQueries() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED")) server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED"))
@@ -84,6 +100,22 @@ class HttpWarehouseResourceGatewayTest {
server.verify(); server.verify();
} }
@Test
void resolveReservedRejectsDifferentPhysicalBinding() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED"))
.andRespond(withSuccess("""
{"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED",
"warehouseId":21,"locationId":99,"status":"OCCUPIED","version":3,
"reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("409")
.hasMessageContaining("does not match");
server.verify();
}
@Test @Test
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() { void resolveFailsClosedWhenWarehouseOmitsReservationFacts() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN")) server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
@@ -188,6 +188,98 @@ class InboundPdaWorkflowServiceTest {
verify(repository).update(targetPallet); verify(repository).update(targetPallet);
} }
@Test
void reservedPalletScanConfirmsOnSiteWithoutReservingAgainAndAdvancesPlan() {
plan.setStatus("RESERVED");
targetPallet.setPalletNo("IP-ACY2-20260909-P001");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
targetPallet.setLockedAt(null);
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L,
"OCCUPIED", "ACY2", 1);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assignResolvedPallet("100", plan, targetPallet, resource);
service.assignResolvedPallet("100", plan, targetPallet, resource);
assertEquals("A002", targetPallet.getPalletNo());
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
org.junit.jupiter.api.Assertions.assertNotNull(targetPallet.getLockedAt());
assertEquals("ASSIGNED", plan.getStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, times(1)).update(targetPallet);
verify(repository, times(1)).update(plan);
}
@Test
void publicAssignmentAlsoConfirmsReservedPalletWithoutReservingAgain() {
plan.setStatus("PENDING_ASSIGNMENT");
targetPallet.setDestinationWarehouseId("88");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
targetPallet.setLockedAt(null);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "reserved-public"));
service.assign("100", "LIP1", targetPallet.getId(),
new PdaRequests.Assign(88L, 188L, 288L, "reserved-public-retry"));
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("ASSIGNED", plan.getStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, times(1)).update(targetPallet);
verify(repository, times(1)).update(plan);
}
@Test
void reservedConfirmationDoesNotRegressAdvancedPlanStatus() {
plan.setStatus("SIGNED");
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
WarehouseResourceGateway.WarehousePalletReference resource =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L,
"OCCUPIED", "ACY2", 1);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
service.assignResolvedPallet("100", plan, targetPallet, resource);
assertEquals("ASSIGNED", targetPallet.getAssignmentStatus());
assertEquals("SIGNED", plan.getStatus());
verify(repository, never()).update(plan);
}
@Test
void reservedPalletScanRejectsAnotherPhysicalPalletOrLocation() {
targetPallet.setWarehousePalletId(188L);
targetPallet.setWarehouseLocationId(288L);
targetPallet.setWarehouseResourceLockId(999L);
targetPallet.setAssignmentStatus("RESERVED");
WarehouseResourceGateway.WarehousePalletReference anotherPallet =
new WarehouseResourceGateway.WarehousePalletReference(189L, "A003", 88L, 288L,
"IDLE", "ACY2", 1);
WarehouseResourceGateway.WarehousePalletReference anotherLocation =
new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 289L,
"IDLE", "ACY2", 1);
assertThrows(ResponseStatusException.class,
() -> service.assignResolvedPallet("100", plan, targetPallet, anotherPallet));
assertThrows(ResponseStatusException.class,
() -> service.assignResolvedPallet("100", plan, targetPallet, anotherLocation));
assertEquals("RESERVED", targetPallet.getAssignmentStatus());
verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any());
verify(repository, never()).update(targetPallet);
}
@Test @Test
void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() { void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() {
targetPallet.setWarehouseResourceLockId(null); targetPallet.setWarehouseResourceLockId(null);
@@ -465,6 +465,37 @@ class ReceivingContainerServiceBehaviorTest {
verify(workflow).compensateReservation("100", 55L, failure); verify(workflow).compensateReservation("100", 55L, failure);
} }
@Test
void bulkReceivingResolvesExactPreReservedPalletWithoutIdleLookup() {
stubCurrent("FBA", "CONT-A");
stubSku();
InboundPlanPallet reserved = planPallet();
reserved.setPalletNo("PAL-RESERVED");
reserved.setWarehousePalletId(30L);
reserved.setWarehouseLocationId(40L);
reserved.setWarehouseResourceLockId(50L);
reserved.setAssignmentStatus("RESERVED");
when(pallets.selectList(any())).thenReturn(List.of(reserved));
WarehouseResourceGateway.WarehousePalletReference occupied =
new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L,
"OCCUPIED", "FBA-1", 0);
when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L))
.thenReturn(occupied);
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key"));
verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L);
verify(warehouse, never()).resolvePallet("PAL-RESERVED");
verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"),
any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved),
org.mockito.ArgumentMatchers.same(occupied));
}
@Test @Test
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
@@ -1,9 +1,11 @@
package com.baiyue.lastleg.inboundplan.service; package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest;
import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse;
import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand;
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ContainerInboundPlanConversionCoordinatorTest { class ContainerInboundPlanConversionCoordinatorTest {
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Mock InboundPlanService plans; @Mock InboundPlanService plans;
@Mock WarehouseResourceGateway warehouse; @Mock WarehouseResourceGateway warehouse;
@Mock ContainerPlanConversionCommandMapper commands; @Mock ContainerPlanConversionCommandMapper commands;
@Mock LastlegOrderNoGenerator numbers;
@Mock PlatformTransactionManager transactionManager;
private ContainerInboundPlanConversionCoordinator coordinator; private ContainerInboundPlanConversionCoordinator coordinator;
private JsonMapper objectMapper; private JsonMapper objectMapper;
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
ReflectionTestUtils.setField(coordinator, "plans", plans); ReflectionTestUtils.setField(coordinator, "plans", plans);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
ReflectionTestUtils.setField(coordinator, "commands", commands); ReflectionTestUtils.setField(coordinator, "commands", commands);
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper); ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
.thenReturn(new SimpleTransactionStatus());
} }
@Test @Test
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class)); verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class));
} }
@Test
void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception {
ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request());
completed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed);
ConvertContainerInboundPlanRequest changed =
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64));
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value());
}
@Test @Test
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception { void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
ConvertContainerInboundPlanRequest request = request(); ConvertContainerInboundPlanRequest request = request();
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null); when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared); when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared);
when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"), when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"),
org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0); org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0);
ResponseStatusException error = assertThrows(ResponseStatusException.class, ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "SECOND-KEY")); () -> coordinator.confirm(request, "SECOND-KEY"));
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals(409, error.getStatusCode().value()); assertEquals(409, error.getStatusCode().value());
} }
@Test
void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request);
stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3));
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale);
when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class),
any(String.class), any());
}
@Test
void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception {
ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request());
when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(failed);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64)));
when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)),
eq(31L), any(), any(), any())).thenReturn(1);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of());
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(
new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY"));
}
@Test
void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED"))));
when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"),
org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1);
assertEquals(409, assertThrows(ResponseStatusException.class,
() -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value());
verify(warehouse).compensatePalletBatch(any());
}
@Test
void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request);
setOnePreparedItem(prepared);
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared);
when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1);
when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED"))));
when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any()))
.thenThrow(new IllegalStateException("commit outcome unknown"));
when(commands.findByBooking(BOOKING_ID)).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).compensatePalletBatch(any());
}
@Test
void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception {
ConvertContainerInboundPlanRequest request = request();
ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request);
committed.setResultJson("[]");
setOnePreparedItem(committed);
when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed);
when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse(
41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED"))));
when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"),
org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1);
assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY"));
verify(warehouse, never()).ensurePalletBatch(any());
verify(warehouse).confirmPalletBatch(any());
}
private ConvertContainerInboundPlanRequest request() { private ConvertContainerInboundPlanRequest request() {
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
} }
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
command.setWarehouseId(31L); command.setWarehouseId(31L);
command.setBatchKey("BATCH-1"); command.setBatchKey("BATCH-1");
command.setStatus(status); command.setStatus(status);
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
command.setPreparationJson(objectMapper.writeValueAsString( command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of()))); new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
return command; return command;
} }
private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception {
var item = new ContainerInboundPlanConversionCoordinator.PreparedItem(
"100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item))));
}
private WarehouseResourceGateway.PalletBatchItem reserved(String status) {
return new WarehouseResourceGateway.PalletBatchItem(
100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status);
}
private ContainerInboundPlanPreviewResponse preview(long version, String hash) {
return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
"Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of());
}
} }
@@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest {
assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers()); assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers());
} }
@Test
void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() {
when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
null, null, null)));
var result = service.previewContainer("BOOKING-NULL");
assertNotNull(result.snapshotHash());
assertEquals(64, result.snapshotHash().length());
assertFalse(result.ready());
}
private InboundPlanOrderMapper.ContainerPreviewOrderRow row( private InboundPlanOrderMapper.ContainerPreviewOrderRow row(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) { int recommendedPallets, String planNo, Integer plannedPallets, int version) {