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:
@@ -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;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+21
-5
@@ -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);
|
||||
}
|
||||
|
||||
+45
-22
@@ -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
|
||||
|
||||
+4
@@ -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");
|
||||
}
|
||||
|
||||
+37
-13
@@ -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");
|
||||
|
||||
+52
-14
@@ -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();
|
||||
|
||||
+136
-48
@@ -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,
|
||||
|
||||
+2
-1
@@ -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合箱幂等操作审计';
|
||||
|
||||
+32
@@ -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"))
|
||||
|
||||
+92
@@ -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);
|
||||
|
||||
+31
@@ -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"));
|
||||
|
||||
+142
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user