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_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 '入仓单号快照',
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),
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)
@@ -1440,7 +1440,7 @@ BEGIN
'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_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='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
END IF;
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_delivery_order','ll_delivery_fee','ll_delivery_operation',
'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='五期完整业务表集合未部署';
END IF;
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)
) 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;
@@ -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摘要',
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 '海外仓托盘批次幂等键',
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 '最近一次跨服务错误',
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),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at)
) 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权威实物托盘码';
SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message;
@@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand {
private Long warehouseId;
private String batchKey;
private String status;
private String leaseToken;
private String preparationJson;
private String resultJson;
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")
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,
@Param("leaseToken") String leaseToken,
@Param("status") String status,
@Param("resultJson") String resultJson, @Param("lastError") String lastError,
@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,
@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,
@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
public WarehousePalletReference resolvePallet(String palletCode) {
WarehousePalletData data = palletData(palletCode);
requireReservable(data);
return pallet(data);
WarehousePalletResponse response = lookupPallet(palletCode);
requireReservable(response.data());
return reference(response.data());
}
@Override
public WarehousePalletReference inspectPallet(String palletCode) {
return pallet(palletData(palletCode));
return reference(lookupPallet(palletCode).data());
}
private WarehousePalletData palletData(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");
@Override
public WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId,
long palletId, long locationId, long resourceLockId) {
if (customerId == null || customerId.isBlank() || warehouseId <= 0 || palletId <= 0
|| locationId <= 0 || resourceLockId <= 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Reserved pallet binding is incomplete");
}
if (response == null || response.data() == null) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Warehouse pallet service returned no usable response");
try {
Long.parseLong(customerId);
} catch (NumberFormatException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Reserved pallet customer binding is invalid");
}
return response.data();
}
private WarehousePalletReference pallet(WarehousePalletData data) {
return new WarehousePalletReference(data.id(), data.palletCode(), data.warehouseId(), data.locationId(),
data.status(), data.fbaWarehouseCode(), data.unitPrice(), data.version());
WarehousePalletData pallet = lookupPallet(palletCode).data();
if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId
|| !"OCCUPIED".equals(pallet.status())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Scanned pallet does not match the reserved inbound plan resource");
}
return reference(pallet);
}
@Override
@@ -169,6 +170,28 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
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) {
HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT
: status.value() == 404 ? HttpStatus.NOT_FOUND
@@ -14,6 +14,10 @@ public interface WarehouseResourceGateway {
default WarehousePalletReference inspectPallet(String 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) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured");
}
@@ -113,9 +113,8 @@ public class InboundPdaWorkflowService {
InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId);
validateDestinationWarehouse(pallet, request.warehouseId());
if (pallet.getWarehouseResourceLockId() != null) {
if (Objects.equals(pallet.getWarehousePalletId(), request.palletId())
&& Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return;
throw conflict("Planned pallet is already assigned to another warehouse resource");
confirmReservedAssignment(plan, pallet, request.palletId(), request.locationId(), null);
return;
}
String destinationKey = destinationKey(pallet);
String businessId = plan.getContainerNo() + ":" + destinationKey;
@@ -130,12 +129,7 @@ public class InboundPdaWorkflowService {
pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now());
repository.update(pallet);
List<InboundPlanPallet> pallets = repository.pallets(plan.getId());
if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) {
plan.setStatus("ASSIGNED");
plan.setUpdatedAt(LocalDateTime.now());
repository.update(plan);
}
promotePlanWhenAllPalletsAssigned(plan);
}
@Transactional
@@ -167,13 +161,13 @@ public class InboundPdaWorkflowService {
@Transactional
public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet,
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()) {
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 businessId = plan.getContainerNo() + ":" + destinationKey;
String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId();
@@ -184,6 +178,7 @@ public class InboundPdaWorkflowService {
pallet.setWarehouseLocationId(resource.locationId());
pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED");
pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet);
promotePlanWhenAllPalletsAssigned(plan);
return lockId;
} catch (RuntimeException failure) {
compensateReservation(customerId, lockId, failure);
@@ -878,6 +873,35 @@ public class InboundPdaWorkflowService {
|| !("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) {
InboundPlan plan = repository.lockPlan(customerId, planNo);
if (plan == null) throw notFound("Inbound plan not found");
@@ -274,14 +274,8 @@ public class ReceivingContainerService {
ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId();
InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode());
WarehouseResourceGateway.WarehousePalletReference pallet;
try {
pallet = warehouse.resolvePallet(request.palletCode());
} catch (IllegalArgumentException exception) {
throw notFound("Pallet was not found");
}
if (pallet == null) throw notFound("Pallet was not found");
validatePallet(ref, pallet);
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
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());
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
int accepted = 0;
@@ -344,9 +338,9 @@ public class ReceivingContainerService {
if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count");
}
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.inspectPallet(request.palletCode());
validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource);
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
@@ -639,6 +633,47 @@ public class ReceivingContainerService {
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) {
if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped");
long actualWarehouse;
@@ -666,6 +701,9 @@ public class ReceivingContainerService {
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
}
private record PalletSelection(InboundPlanPallet pallet,
WarehouseResourceGateway.WarehousePalletReference resource) { }
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream()
.map(this::inbound).toList();
@@ -46,82 +46,108 @@ public class ContainerInboundPlanConversionCoordinator {
String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空");
String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符");
String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash()));
ContainerPlanConversionCommand resolved = commands.find(bookingId, key);
if (resolved == null) resolved = commands.findByBooking(bookingId);
if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash);
ContainerPlanConversionCommand direct = commands.find(bookingId, key);
ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct;
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;
requireSameRequest(command, requestHash);
Preparation preparation = read(command.getPreparationJson(), Preparation.class);
if ("COMPLETED".equals(command.getStatus())) return result(command);
boolean applyingLeaseOwned = false;
if ("APPLYING".equals(command.getStatus())) {
LocalDateTime now = LocalDateTime.now();
String leaseToken = IdWorker.getIdStr();
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, "该柜正在生成入库计划,请勿重复提交");
}
command.setLeaseToken(leaseToken);
command.setUpdatedAt(now);
applyingLeaseOwned = true;
}
if (!"LOCAL_COMMITTED".equals(command.getStatus())) {
if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) {
if ("PREPARED".equals(command.getStatus())) {
String leaseToken = IdWorker.getIdStr();
if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) {
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) return result(current);
throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交");
}
command.setStatus("APPLYING");
command.setLeaseToken(leaseToken);
applyingLeaseOwned = true;
}
if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效");
}
if (preparation.items().isEmpty()) {
try {
List<InboundPlanResponse> existing = inTransaction(() -> {
List<InboundPlanResponse> converted = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), List.of());
state(command.getId(), "APPLYING", "COMPLETED", write(converted), null);
state(command, "APPLYING", "COMPLETED", write(converted), null);
return converted;
});
command.setStatus("COMPLETED");
return existing;
} 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;
}
}
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;
if ("LOCAL_COMMITTED".equals(command.getStatus())) {
localResult = result(command);
} 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 {
List<InboundPlanService.PreparedPlan> preparedPlans = bind(preparation, reservation);
localResult = inTransaction(() -> {
List<InboundPlanResponse> created = plans.convertContainerReserved(bookingId,
request.snapshotVersion(), request.snapshotHash(), preparedPlans);
state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null);
state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null);
return created;
});
command.setStatus("LOCAL_COMMITTED");
} catch (RuntimeException failure) {
try {
warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation(
preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(),
"Lastleg local transaction did not commit"));
} catch (RuntimeException compensationFailure) {
failure.addSuppressed(compensationFailure);
ContainerPlanConversionCommand current = commands.findByBooking(bookingId);
if (current != null && "COMPLETED".equals(current.getStatus())) {
return result(current);
}
if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) {
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,
ConvertContainerInboundPlanRequest request,
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);
if (preview.snapshotVersion() != request.snapshotVersion()
|| !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) {
@@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator {
catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
}
long commandId = IdWorker.getId();
List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0;
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
@@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator {
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
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));
}
}
@@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator {
command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key);
command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion());
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.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;
}
@@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator {
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
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)
.noneMatch(status -> status.equals(response.status()))) {
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) {
@@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator {
}
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");
}
private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) {
private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) {
if ("APPLYING".equals(command.getStatus())) {
state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure));
command.setStatus("PREPARED");
state(command, "APPLYING", "FAILED", null, safeMessage(failure));
command.setStatus("FAILED");
}
}
private void state(long id, String expectedStatus, String status, String resultJson, String lastError) {
if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) {
private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation,
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("无法更新柜级转换命令状态");
}
}
@@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator {
String message = value.getMessage();
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 PreparedItem(String customerId, long orderId, long planId, String planNo,
@@ -198,7 +198,8 @@ public class InboundPlanService {
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
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,
warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked,
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;
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;
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 (
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 '币种',
@@ -589,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command (
snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256',
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',
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 '最近一次跨服务错误',
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),
UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key),
KEY idx_ll_container_conversion_status(status,updated_at)
) 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 (
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',
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 '目的仓名称快照',
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),
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)
@@ -1590,23 +1591,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址',
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NOT 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 '原海外中转仓名称快照',
destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (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_source_position (customer_id,source_inventory_position_id)
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
) 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;
@@ -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)
) 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 (
id BIGINT NOT NULL COMMENT '客户回访记录ID',
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)
) 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 投影。
INSERT IGNORE INTO ll_security_projection (
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),
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) 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();
}
@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
void inspectAllowsOccupiedPalletForReadOnlyInventoryQueries() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED"))
@@ -84,6 +100,22 @@ class HttpWarehouseResourceGatewayTest {
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
void resolveFailsClosedWhenWarehouseOmitsReservationFacts() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN"))
@@ -188,6 +188,98 @@ class InboundPdaWorkflowServiceTest {
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
void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() {
targetPallet.setWarehouseResourceLockId(null);
@@ -465,6 +465,37 @@ class ReceivingContainerServiceBehaviorTest {
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
void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() {
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
@@ -1,9 +1,11 @@
package com.baiyue.lastleg.inboundplan.service;
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.mapper.ContainerPlanConversionCommandMapper;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
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.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
@ExtendWith(MockitoExtension.class)
class ContainerInboundPlanConversionCoordinatorTest {
@@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest {
@Mock InboundPlanService plans;
@Mock WarehouseResourceGateway warehouse;
@Mock ContainerPlanConversionCommandMapper commands;
@Mock LastlegOrderNoGenerator numbers;
@Mock PlatformTransactionManager transactionManager;
private ContainerInboundPlanConversionCoordinator coordinator;
private JsonMapper objectMapper;
@@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest {
ReflectionTestUtils.setField(coordinator, "plans", plans);
ReflectionTestUtils.setField(coordinator, "warehouse", warehouse);
ReflectionTestUtils.setField(coordinator, "commands", commands);
ReflectionTestUtils.setField(coordinator, "numbers", numbers);
ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper);
ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager);
org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any()))
.thenReturn(new SimpleTransactionStatus());
}
@Test
@@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest {
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
void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception {
ConvertContainerInboundPlanRequest request = request();
@@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null);
when(commands.findByBooking(BOOKING_ID)).thenReturn(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,
() -> coordinator.confirm(request, "SECOND-KEY"));
@@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest {
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() {
return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH);
}
@@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest {
command.setWarehouseId(31L);
command.setBatchKey("BATCH-1");
command.setStatus(status);
if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1");
command.setPreparationJson(objectMapper.writeValueAsString(
new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of())));
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());
}
@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(
long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName,
int recommendedPallets, String planNo, Integer plannedPallets, int version) {