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

This commit is contained in:
2026-09-04 12:55:52 +08:00
15 changed files with 343 additions and 107 deletions
@@ -10450,8 +10450,22 @@ DELIMITER ;
CALL assert_phase3_final_schema(); CALL assert_phase3_final_schema();
DROP PROCEDURE IF EXISTS assert_phase3_final_schema; DROP PROCEDURE IF EXISTS assert_phase3_final_schema;
-- PDA 部分拣货释放策略:NULL/AUTO 按比例释放,MANUAL + [] 表示人工明确不释放。 -- PDA 整票库位块直接使用 Firstleg 托盘分配快照;旧明细级释放策略字段不再保留。
USE baiyue_firstleg; USE baiyue_firstleg;
SET @phase3_pda_drop_release_locations = IF((SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_item'
AND column_name='release_location_ids') > 0,
'ALTER TABLE fl_loading_pick_item DROP COLUMN release_location_ids', 'SELECT 1');
PREPARE phase3_pda_drop_release_locations_stmt FROM @phase3_pda_drop_release_locations;
EXECUTE phase3_pda_drop_release_locations_stmt;
DEALLOCATE PREPARE phase3_pda_drop_release_locations_stmt;
SET @phase3_pda_drop_release_mode = IF((SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='fl_loading_pick_item'
AND column_name='release_mode') > 0,
'ALTER TABLE fl_loading_pick_item DROP COLUMN release_mode', 'SELECT 1');
PREPARE phase3_pda_drop_release_mode_stmt FROM @phase3_pda_drop_release_mode;
EXECUTE phase3_pda_drop_release_mode_stmt;
DEALLOCATE PREPARE phase3_pda_drop_release_mode_stmt;
DROP PROCEDURE IF EXISTS phase3_pda_add_column; DROP PROCEDURE IF EXISTS phase3_pda_add_column;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE phase3_pda_add_column( CREATE PROCEDURE phase3_pda_add_column(
@@ -10471,14 +10485,6 @@ BEGIN
END IF; END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL phase3_pda_add_column(
'fl_loading_pick_item', 'release_mode',
"VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放' AFTER item_status"
);
CALL phase3_pda_add_column(
'fl_loading_pick_item', 'release_location_ids',
"JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个' AFTER release_mode"
);
CALL phase3_pda_add_column( CALL phase3_pda_add_column(
'fl_inbound_pallet_allocation', 'block_id', 'fl_inbound_pallet_allocation', 'block_id',
"BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID' AFTER allocation_no" "BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse整票连续库位块ID' AFTER allocation_no"
@@ -13971,8 +13977,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'loading_pl
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'pick_task_id', 'PDA拣货任务ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'pick_task_id', 'PDA拣货任务ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'picked_package_count', '已拣货箱数'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'picked_package_count', '已拣货箱数');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'planned_package_count', '计划拣货箱数;上层拆分按业务规则向上取整'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'planned_package_count', '计划拣货箱数;上层拆分按业务规则向上取整');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_location_ids', '人工选择释放库位ID;空数组表示明确释放0个');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_mode', 'AUTO按比例释放,MANUAL按人工选择释放');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'updated_at', 'PDA拣货明细最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'updated_at', 'PDA拣货明细最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'version', '乐观锁版本号');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'assigned_by', '执行领取或分配操作的用户ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item_assignment', 'assigned_by', '执行领取或分配操作的用户ID');
@@ -14991,17 +14995,24 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_pallet_location_transfer_opera
USE baiyue_warehouse; USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_location_block ( CREATE TABLE IF NOT EXISTS wh_location_block (
id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
warehouse_id BIGINT UNSIGNED NOT NULL, source_service VARCHAR(40) NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
business_id BIGINT UNSIGNED NOT NULL, inbound_code VARCHAR(40) NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
slot_count INT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
block_status VARCHAR(20) NOT NULL, expires_at DATETIME(3) NOT NULL, business_id BIGINT UNSIGNED NOT NULL COMMENT '当前归属入库单业务ID',
last_operation_key VARCHAR(128) NULL, created_by BIGINT UNSIGNED NOT NULL, inbound_code VARCHAR(40) NOT NULL COMMENT '当前归属入仓码',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), slot_count INT UNSIGNED NOT NULL COMMENT '整票独占槽位数',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), idempotency_key VARCHAR(128) NOT NULL COMMENT '预占幂等键',
version INT NOT NULL DEFAULT 0, block_status VARCHAR(20) NOT NULL COMMENT '整票块状态:RESERVED、OCCUPIED或RELEASED',
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
last_operation_key VARCHAR(128) NULL COMMENT '最近确认、转移或释放幂等键',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_business_block TINYINT GENERATED ALWAYS AS active_business_block TINYINT GENERATED ALWAYS AS
(CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED, (CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED
COMMENT '同一来源入库单仅允许一个活动整票块',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_location_block_idempotency (tenant_id, source_service, idempotency_key), UNIQUE KEY uk_location_block_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_block_active_business (tenant_id, source_service, business_id, active_business_block), UNIQUE KEY uk_location_block_active_business (tenant_id, source_service, business_id, active_business_block),
@@ -15011,15 +15022,19 @@ CREATE TABLE IF NOT EXISTS wh_location_block (
) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块'; ) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块';
CREATE TABLE IF NOT EXISTS wh_location_block_slot ( CREATE TABLE IF NOT EXISTS wh_location_block_slot (
id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId', id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, block_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
slot_no INT UNSIGNED NOT NULL, location_id BIGINT UNSIGNED NOT NULL, block_id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
location_code VARCHAR(50) NOT NULL, shared_overflow TINYINT(1) NOT NULL DEFAULT 0, slot_no INT UNSIGNED NOT NULL COMMENT '块内稳定槽位序号,从1开始',
resource_status VARCHAR(20) NOT NULL, location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否落入同仓共享A0',
version INT NOT NULL DEFAULT 0, resource_status VARCHAR(20) NOT NULL COMMENT '资源状态:RESERVED、OCCUPIED或RELEASED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_normal_slot TINYINT GENERATED ALWAYS AS active_normal_slot TINYINT GENERATED ALWAYS AS
(CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED, (CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED
COMMENT '普通库位活动块槽唯一标记;A0返回NULL',
PRIMARY KEY (id), UNIQUE KEY uk_location_block_slot_no (tenant_id, block_id, slot_no), PRIMARY KEY (id), UNIQUE KEY uk_location_block_slot_no (tenant_id, block_id, slot_no),
UNIQUE KEY uk_location_block_active_normal (tenant_id, location_id, active_normal_slot), UNIQUE KEY uk_location_block_active_normal (tenant_id, location_id, active_normal_slot),
KEY idx_location_block_slot_block (tenant_id, block_id, resource_status), KEY idx_location_block_slot_block (tenant_id, block_id, resource_status),
@@ -46,13 +46,6 @@ public interface WarehousePalletLocationClient {
ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockReleaseRequest request); @RequestBody BlockReleaseRequest request);
@GetMapping("/pallet-locations/occupied-resources")
ApiResponse<List<OccupiedResource>> occupiedResources(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@RequestParam("sourceService") String sourceService,
@RequestParam("businessId") long businessId);
record BlockReserveRequest(long warehouseId, String sourceService, long businessId, record BlockReserveRequest(long warehouseId, String sourceService, long businessId,
String inboundCode, int requiredSlotCount, String idempotencyKey, String inboundCode, int requiredSlotCount, String idempotencyKey,
long operatorId, LocalDateTime expiresAt) {} long operatorId, LocalDateTime expiresAt) {}
@@ -65,9 +58,6 @@ public interface WarehousePalletLocationClient {
record BlockResource(long blockId, long reservationId, long locationId, String locationCode, record BlockResource(long blockId, long reservationId, long locationId, String locationCode,
int slotNo, boolean sharedOverflow, String resourceStatus) {} int slotNo, boolean sharedOverflow, String resourceStatus) {}
record BlockResult(long blockId, String blockStatus, List<BlockResource> resources) {} record BlockResult(long blockId, String blockStatus, List<BlockResource> resources) {}
record OccupiedResource(long reservationId, long occupancyId, int allocationNo, long warehouseId,
long palletId, long locationId, String locationCode, boolean sharedOverflow,
int allocatedPackageCount) {}
record PalletSnapshot(long palletId, String palletCode, String palletType, record PalletSnapshot(long palletId, String palletCode, String palletType,
BigDecimal capacityCbm, BigDecimal tareWeightKg) {} BigDecimal capacityCbm, BigDecimal tareWeightKg) {}
} }
@@ -92,6 +92,7 @@ public final class LoadingPickDtos {
public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId, public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes, String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
List<InventoryResource> inventoryResources,
Integer plannedPackageCount, Integer pickedPackageCount, Integer plannedPackageCount, Integer pickedPackageCount,
String itemStatus, Integer version) { String itemStatus, Integer version) {
} }
@@ -131,9 +132,10 @@ public final class LoadingPickDtos {
Integer itemCount, Integer pendingItemCount) { Integer itemCount, Integer pendingItemCount) {
} }
public record InventoryResource(Long reservationId, Long occupancyId, Long palletId, public record InventoryResource(Long blockId, Integer slotNo, Long reservationId,
Long weightResultId, Integer palletSequence, Long palletId,
Long locationId, String locationCode, Boolean sharedOverflow, Long locationId, String locationCode, Boolean sharedOverflow,
Integer availablePackageCount) { String slotStatus, Integer availablePackageCount) {
} }
public record InboundInventory(Long inboundId, Integer availablePackageCount, public record InboundInventory(Long inboundId, Integer availablePackageCount,
@@ -1,9 +1,7 @@
package com.baiyue.firstleg.service; package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
@@ -34,6 +32,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService; import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
@@ -46,12 +45,11 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -80,8 +78,6 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@Service @Service
public class LoadingPickService { public class LoadingPickService {
private static final Logger log = LoggerFactory.getLogger(LoadingPickService.class); private static final Logger log = LoggerFactory.getLogger(LoadingPickService.class);
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
private static final String SOURCE_SERVICE = "firstleg-service";
@Resource @Resource
private LoadingPickRepository repository; private LoadingPickRepository repository;
@Resource @Resource
@@ -107,9 +103,7 @@ public class LoadingPickService {
@Resource @Resource
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@Resource @Resource
private WarehousePalletLocationClient warehouse; private InboundPalletAllocationRepository palletAllocations;
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */ /** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional @Transactional
@@ -908,14 +902,19 @@ public class LoadingPickService {
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream() Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId)); .collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
booking.getWarehouseId(), source, tenantByInbound);
List<PickItem> items = source.stream().map(item -> { List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of()); List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0); CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
return new PickItem(item.getId(), item.getLoadingPlanItemId(), first == null ? null : first.getTenantId(), Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId();
return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId,
item.getInboundId(), item.getInboundId(),
item.getInboundDetailId(), first == null ? null : first.getOrderNo(), item.getInboundDetailId(), first == null ? null : first.getOrderNo(),
first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(), first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
cartons.stream().map(CartonLabelRow::getBoxCode).toList(), item.getPlannedPackageCount(), cartons.stream().map(CartonLabelRow::getBoxCode).toList(),
inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(),
item.getPickedPackageCount(), item.getItemStatus(), item.getVersion()); item.getPickedPackageCount(), item.getItemStatus(), item.getVersion());
}).toList(); }).toList();
LoadingPickAssignmentEntity assigned = repository.assignment(task.getId()); LoadingPickAssignmentEntity assigned = repository.assignment(task.getId());
@@ -1038,17 +1037,11 @@ public class LoadingPickService {
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId, private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
List<LoadingPickItemEntity> items) { List<LoadingPickItemEntity> items) {
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
warehouseId, items, tenantByInbound(task.getId()));
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId) List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> { .filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
List<WarehousePalletLocationClient.OccupiedResource> current = occupiedResources(inboundId); List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
List<InventoryResource> resources = current.stream()
.filter(resource -> resource.warehouseId() == warehouseId)
.sorted(Comparator.comparingLong(WarehousePalletLocationClient.OccupiedResource::reservationId)
.thenComparingLong(WarehousePalletLocationClient.OccupiedResource::occupancyId))
.map(resource -> new InventoryResource(resource.reservationId(), resource.occupancyId(),
resource.palletId(), resource.locationId(), resource.locationCode(),
resource.sharedOverflow(), resource.allocatedPackageCount()))
.toList();
int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum(); int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum();
return new InboundInventory(inboundId, available, resources); return new InboundInventory(inboundId, available, resources);
}).toList(); }).toList();
@@ -1059,20 +1052,54 @@ public class LoadingPickService {
LocalDateTime.now(), token, inbounds); LocalDateTime.now(), token, inbounds);
} }
private List<WarehousePalletLocationClient.OccupiedResource> occupiedResources(long inboundId) { private Map<Long, Long> tenantByInbound(long taskId) {
ApiResponse<List<WarehousePalletLocationClient.OccupiedResource>> response; return repository.inboundOwners(taskId).stream().collect(Collectors.toMap(
try { LoadingPickItemMapper.InboundOwner::inboundId,
response = warehouse.occupiedResources(internalToken, PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID, LoadingPickItemMapper.InboundOwner::tenantId,
SOURCE_SERVICE, inboundId); (left, right) -> {
} catch (FeignException exception) { if (!Objects.equals(left, right)) throw conflict("PDA任务入库单客户归属冲突");
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储实时库存查询失败,请刷新后重试"); return left;
}));
} }
if (response == null || !response.successful() || response.data() == null) {
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); private Map<Long, List<InventoryResource>> inventoryByInbound(long warehouseId,
throw new BizException(code, response == null || response.message() == null List<LoadingPickItemEntity> items,
? "仓储实时库存查询失败,请刷新后重试" : response.message()); Map<Long, Long> tenantByInbound) {
Map<Long, List<InventoryResource>> result = new HashMap<>();
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
.filter(Objects::nonNull).distinct().toList()) {
Long tenantId = tenantByInbound.get(inboundId);
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
.filter(resource -> Objects.equals(resource.getWarehouseId(), warehouseId))
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
Comparator.nullsLast(Long::compareTo))
.thenComparing(InboundPalletAllocationEntity::getSlotNo,
Comparator.nullsLast(Integer::compareTo)))
.map(LoadingPickService::inventoryResource)
.toList();
result.put(inboundId, resources);
} }
return response.data(); return result;
}
private static InventoryResource inventoryResource(InboundPalletAllocationEntity resource) {
if (resource.getBlockId() == null || resource.getSlotNo() == null
|| resource.getReservationId() == null || resource.getLocationId() == null) {
throw conflict("入库单库位块快照不完整,请重新确认测量入库");
}
return new InventoryResource(resource.getBlockId(), resource.getSlotNo(),
resource.getReservationId(), resource.getWeightResultId(), resource.getPalletSequence(),
resource.getPalletId(), resource.getLocationId(), resource.getLocationCode(),
Boolean.TRUE.equals(resource.getSharedOverflow()), resource.getSlotStatus(), available(resource));
}
private static int available(InboundPalletAllocationEntity resource) {
int allocated = count(resource.getAllocatedPackageCount());
int picked = count(resource.getPickedPackageCount());
if (picked > allocated) throw conflict("整票库位槽已拣箱数超过分配箱数");
return allocated - picked;
} }
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId, private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId,
@@ -1082,8 +1109,9 @@ public class LoadingPickService {
for (InboundInventory inbound : inbounds) { for (InboundInventory inbound : inbounds) {
canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount()); canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount());
for (InventoryResource resource : inbound.resources()) { for (InventoryResource resource : inbound.resources()) {
canonical.append('|').append(resource.reservationId()).append('|').append(resource.occupancyId()) canonical.append('|').append(resource.blockId()).append('|').append(resource.slotNo())
.append('|').append(resource.locationId()).append('|').append(resource.availablePackageCount()); .append('|').append(resource.reservationId()).append('|').append(resource.locationId())
.append('|').append(resource.availablePackageCount());
} }
} }
return hash(canonical.toString()); return hash(canonical.toString());
@@ -294,7 +294,7 @@ components:
items: {$ref: '#/components/schemas/PickItem'} items: {$ref: '#/components/schemas/PickItem'}
PickItem: PickItem:
type: object type: object
required: [id, loadingPlanItemId, tenantId, inboundId, inboundDetailId, boxCodes, plannedPackageCount, pickedPackageCount, itemStatus, version] required: [id, loadingPlanItemId, tenantId, inboundId, inboundDetailId, boxCodes, inventoryResources, plannedPackageCount, pickedPackageCount, itemStatus, version]
properties: properties:
id: {type: integer, format: int64} id: {type: integer, format: int64}
loadingPlanItemId: {type: integer, format: int64} loadingPlanItemId: {type: integer, format: int64}
@@ -308,6 +308,10 @@ components:
type: array type: array
description: Stable carton label codes; one successful scan represents one carton description: Stable carton label codes; one successful scan represents one carton
items: {type: string} items: {type: string}
inventoryResources:
type: array
description: Current authoritative whole-block slot snapshots for this inbound; never projected from legacy occupancy rows
items: {$ref: '#/components/schemas/InventoryResource'}
plannedPackageCount: {type: integer, minimum: 1} plannedPackageCount: {type: integer, minimum: 1}
pickedPackageCount: {type: integer, minimum: 0} pickedPackageCount: {type: integer, minimum: 0}
itemStatus: {type: string, enum: [PENDING, PICKING, PICKED, PARTIAL, SKIPPED]} itemStatus: {type: string, enum: [PENDING, PICKING, PICKED, PARTIAL, SKIPPED]}
@@ -427,12 +431,16 @@ components:
items: {$ref: '#/components/schemas/InventoryResource'} items: {$ref: '#/components/schemas/InventoryResource'}
InventoryResource: InventoryResource:
type: object type: object
required: [reservationId, occupancyId, palletId, locationId, sharedOverflow, availablePackageCount] required: [blockId, slotNo, reservationId, locationId, sharedOverflow, slotStatus, availablePackageCount]
properties: properties:
blockId: {type: integer, format: int64}
slotNo: {type: integer, minimum: 1}
reservationId: {type: integer, format: int64} reservationId: {type: integer, format: int64}
occupancyId: {type: integer, format: int64} weightResultId: {type: integer, format: int64, nullable: true}
palletId: {type: integer, format: int64} palletSequence: {type: integer, minimum: 1, nullable: true}
palletId: {type: integer, format: int64, nullable: true}
locationId: {type: integer, format: int64} locationId: {type: integer, format: int64}
locationCode: {type: string, nullable: true} locationCode: {type: string, nullable: true}
sharedOverflow: {type: boolean} sharedOverflow: {type: boolean}
availablePackageCount: {type: integer, minimum: 1} slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND]}
availablePackageCount: {type: integer, minimum: 0}
@@ -52,6 +52,8 @@ class PdaConvergenceOpenApiContractTest {
String batchItemRequest = yaml.substring( String batchItemRequest = yaml.substring(
yaml.indexOf(" BatchItemRequest:"), yaml.indexOf(" BatchItemRequest:"),
yaml.indexOf(" ReviewRequest:")); yaml.indexOf(" ReviewRequest:"));
String inventoryResource = yaml.substring(
yaml.indexOf(" InventoryResource:"));
assertThat(taskLookup) assertThat(taskLookup)
.contains("name: cabinetNo", "name: loadingPlanId") .contains("name: cabinetNo", "name: loadingPlanId")
.doesNotContain("name: containerNo"); .doesNotContain("name: containerNo");
@@ -64,5 +66,10 @@ class PdaConvergenceOpenApiContractTest {
.doesNotContain("containerNo:"); .doesNotContain("containerNo:");
assertThat(batchItemRequest) assertThat(batchItemRequest)
.doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); .doesNotContain("releaseMode:", "reservationIds:", "locationIds:");
assertThat(inventoryResource)
.contains("blockId:", "slotNo:", "reservationId:", "weightResultId:", "slotStatus:")
.doesNotContain("occupancyId:");
assertThat(yaml).contains("inventoryResources:",
"never projected from legacy occupancy rows");
} }
} }
@@ -92,7 +92,7 @@ class LoadingPickItemMapperContractTest {
void pdaWritesAndReadsRequireWarehouseScope() throws Exception { void pdaWritesAndReadsRequireWarehouseScope() throws Exception {
Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class); Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.class);
Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class, Method pick = LoadingPickItemMapper.class.getDeclaredMethod("pick", long.class, int.class,
String.class, String.class, int.class, long.class); int.class, long.class);
assertThat(list.getAnnotation(Select.class).value()[0]) assertThat(list.getAnnotation(Select.class).value()[0])
.contains("booking.warehouse_id = #{warehouseId}") .contains("booking.warehouse_id = #{warehouseId}")
@@ -157,11 +157,22 @@ class PdaSplitSettlementSqlContractTest {
.contains("allocation_status = 'CONFIRMED'") .contains("allocation_status = 'CONFIRMED'")
.doesNotContain("THEN 'RELEASED'"); .doesNotContain("THEN 'RELEASED'");
String release = InboundPalletAllocationMapper.class.getMethod("markReleased", String release = InboundPalletAllocationMapper.class.getMethod("markBlockReleased",
long.class, long.class, long.class) long.class, long.class, long.class)
.getAnnotation(Update.class).value()[0]; .getAnnotation(Update.class).value()[0];
assertThat(release) assertThat(release)
.contains("allocation_status='RELEASED'") .contains("allocation_status='RELEASED'")
.contains("slot_status='RELEASED'")
.contains("block_id=#{blockId}")
.contains("allocation_status='CONFIRMED'");
String transfer = InboundPalletAllocationMapper.class.getMethod("markBlockTransferred",
long.class, long.class, long.class)
.getAnnotation(Update.class).value()[0];
assertThat(transfer)
.contains("allocation_status='TRANSFERRED'")
.contains("slot_status='TRANSFERRED'")
.contains("block_id=#{blockId}")
.contains("allocation_status='CONFIRMED'"); .contains("allocation_status='CONFIRMED'");
} }
} }
@@ -252,6 +252,21 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE PROCEDURE phase3_pda_replace_operation_check"); .contains("CREATE PROCEDURE phase3_pda_replace_operation_check");
} }
@Test
void unifiedPdaBlockDropsLegacyPerItemReleaseColumns() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
assertThat(sql)
.contains("column_name='release_location_ids'")
.contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_location_ids")
.contains("column_name='release_mode'")
.contains("ALTER TABLE fl_loading_pick_item DROP COLUMN release_mode")
.doesNotContain("'fl_loading_pick_item', 'release_mode'")
.doesNotContain("'fl_loading_pick_item', 'release_location_ids'")
.doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_mode'")
.doesNotContain("CALL queue_column_comment('baiyue_firstleg', 'fl_loading_pick_item', 'release_location_ids'");
}
@Test @Test
void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception { void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception {
String sql = normalise(Files.readString(resolveUnified())); String sql = normalise(Files.readString(resolveUnified()));
@@ -11,8 +11,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.client.WarehousePalletLocationClient; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -33,6 +32,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -100,7 +100,7 @@ class LoadingPickServiceTest {
@MockBean @MockBean
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@MockBean @MockBean
private WarehousePalletLocationClient warehouse; private InboundPalletAllocationRepository palletAllocations;
@Resource @Resource
private LoadingPickService service; private LoadingPickService service;
@@ -122,6 +122,10 @@ class LoadingPickServiceTest {
when(completionCommands.insert(any())).thenReturn(1); when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
when(repository.inboundOwners(anyLong())).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation ->
new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1));
} }
@Test @Test
@@ -437,6 +441,7 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(false); when(repository.allTaskItemsResolved(20L)).thenReturn(false);
when(repository.inboundOwners(20L)).thenReturn(List.of( when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L))); new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2)));
when(repository.inbound(101L, 50L)).thenReturn( when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
var result = service.task(20L, 101L); var result = service.task(20L, 101L);
@@ -449,6 +454,14 @@ class LoadingPickServiceTest {
assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull(); assertThat(result.mergedIntoContainerBookingId()).isNull();
assertThat(result.items().get(0).inventoryResources()).singleElement().satisfies(resource -> {
assertThat(resource.blockId()).isEqualTo(71L);
assertThat(resource.slotNo()).isEqualTo(1);
assertThat(resource.reservationId()).isEqualTo(72L);
assertThat(resource.locationId()).isEqualTo(74L);
assertThat(resource.availablePackageCount()).isEqualTo(2);
});
verify(palletAllocations).list(101L, 50L);
verify(completionCommands, never()).latest(20L); verify(completionCommands, never()).latest(20L);
} }
@@ -458,7 +471,9 @@ class LoadingPickServiceTest {
when(repository.task(20L)).thenReturn(task); when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item())); when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of()); when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of()); when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of());
doThrow(new IllegalStateException("completion command schema unavailable")) doThrow(new IllegalStateException("completion command schema unavailable"))
.when(completionCommands).latest(20L); .when(completionCommands).latest(20L);
@@ -1150,10 +1165,17 @@ class LoadingPickServiceTest {
when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( when(repository.inboundOwners(20L)).thenReturn(List.of(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( new LoadingPickItemMapper.InboundOwner(1L, 50L),
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); new LoadingPickItemMapper.InboundOwner(1L, 51L)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
var snapshot = service.inventorySnapshot(20L, 101L);
String snapshotToken = snapshot.snapshotToken();
assertThat(snapshot.inbounds()).hasSize(2);
assertThat(snapshot.inbounds().get(0).resources().get(0))
.extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount")
.containsExactly(71L, 1, 72L, 74L, 2);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null), new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton"))); new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton")));
@@ -1191,9 +1213,10 @@ class LoadingPickServiceTest {
when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
when(smartLoadingRepository.operation("PDA-START-20")).thenReturn(existingStart); when(smartLoadingRepository.operation("PDA-START-20")).thenReturn(existingStart);
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( when(repository.inboundOwners(20L)).thenReturn(List.of(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( new LoadingPickItemMapper.InboundOwner(1L, 50L)));
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 100)))); when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 100)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 100, null, 0, null))); new BatchItemRequest(30L, "PICK", 100, null, 0, null)));
@@ -1242,9 +1265,10 @@ class LoadingPickServiceTest {
when(repository.assignment(20L)).thenReturn(assignment()); when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(picked)); when(repository.items(20L)).thenReturn(List.of(picked));
when(repository.items(20L, 101L)).thenReturn(List.of(picked)); when(repository.items(20L, 101L)).thenReturn(List.of(picked));
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( when(repository.inboundOwners(20L)).thenReturn(List.of(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( new LoadingPickItemMapper.InboundOwner(1L, 50L)));
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of( BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null))); new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
@@ -1285,9 +1309,10 @@ class LoadingPickServiceTest {
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1); when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.touchTask(20L, 0)).thenReturn(1);
when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( when(repository.inboundOwners(20L)).thenReturn(List.of(
ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( new LoadingPickItemMapper.InboundOwner(1L, 50L)));
71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null); BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null);
@@ -1405,6 +1430,27 @@ class LoadingPickServiceTest {
return item; return item;
} }
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) {
InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity();
allocation.setId(72L);
allocation.setTenantId(1L);
allocation.setInboundId(inboundId);
allocation.setBlockId(71L);
allocation.setSlotNo(1);
allocation.setAllocationNo(1);
allocation.setWarehouseId(101L);
allocation.setPalletId(73L);
allocation.setLocationId(74L);
allocation.setLocationCode("A-01");
allocation.setSharedOverflow(false);
allocation.setReservationId(72L);
allocation.setSlotStatus("BOUND");
allocation.setAllocationStatus("CONFIRMED");
allocation.setAllocatedPackageCount(packageCount);
allocation.setPickedPackageCount(0);
return allocation;
}
private static LoadingPickAssignmentEntity assignment() { private static LoadingPickAssignmentEntity assignment() {
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity(); LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
assignment.setPickTaskId(20L); assignment.setPickTaskId(20L);
+2
View File
@@ -288,6 +288,7 @@ function usage() {
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换 --wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
--wait-split-sequence <n> 单票全局序号,正整数,默认1 --wait-split-sequence <n> 单票全局序号,正整数,默认1
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号 --wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
--verify-pallet-location-guide <true> 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
--route-mode <mode> 线路选择:fixed 或 random,默认fixed --route-mode <mode> 线路选择:fixed 或 random,默认fixed
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用 --manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 --manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
@@ -348,6 +349,7 @@ export function parseArguments(argv) {
waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0),
waitSplitSequence: Number(values.get('wait-split-sequence') || 1), waitSplitSequence: Number(values.get('wait-split-sequence') || 1),
waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1), waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1),
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
routeMode: values.get('route-mode') || 'fixed', routeMode: values.get('route-mode') || 'fixed',
quiet: values.get('quiet') === 'true', quiet: values.get('quiet') === 'true',
manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '',
+6
View File
@@ -128,6 +128,12 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize
``` ```
整票库位块专项可在只跑 `orders,inbound-measurement` 时追加
`--verify-pallet-location-guide true`。它在测量提交后核对
`palletLocationGuide` 计数、每槽 `blockId/slotNo/reservationId/locationCode/slotStatus`、
称重结果按 `weightResultId` 显式绑定槽位,并真实验证删除图片后原 reservation/location
保留且转为 `RESERVED_EMPTY`。脚本只观测自然出现的共享 A0;不会为制造普通位不足批量停用库位。
只验证真实集装箱号晚补门禁时,使用正式可选参数 `LATE_CONTAINER_GATE_ONLY=1` 并仍从总入口执行 `phase3-loading`。该模式使用本轮 marker 独立造数,固定验证无号 push、订舱关联、整份预装确认、PDA claim/batch、无号 complete `A0409`、LOADING 阶段首次补号和补号后 complete 受理;随后允许后台命令停在 `CABINET_EVIDENCE/A0409`,以证明柜号门禁已经消失且下一门禁来自未上传的五类柜级证据。该模式不继续完整报关或 LOADED 链,也不得与完整链同时解释为同一终态验收。 只验证真实集装箱号晚补门禁时,使用正式可选参数 `LATE_CONTAINER_GATE_ONLY=1` 并仍从总入口执行 `phase3-loading`。该模式使用本轮 marker 独立造数,固定验证无号 push、订舱关联、整份预装确认、PDA claim/batch、无号 complete `A0409`、LOADING 阶段首次补号和补号后 complete 受理;随后允许后台命令停在 `CABINET_EVIDENCE/A0409`,以证明柜号门禁已经消失且下一门禁来自未上传的五类柜级证据。该模式不继续完整报关或 LOADED 链,也不得与完整链同时解释为同一终态验收。
```powershell ```powershell
@@ -69,6 +69,16 @@ test('demo package keeps pallet pages bounded and historical snapshots read-only
assert.match(measurementTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/) assert.match(measurementTests, /detailUsesStoredPalletSnapshotAfterCandidateBecomesUnavailable/)
}) })
test('opt-in HTTP acceptance verifies whole-ticket slot identity and delete-to-empty semantics', () => {
assert.match(source, /verifyPalletLocationGuide/)
assert.match(source, /expectedWeightResultIds\.every/)
assert.match(source, /asId\(row\.reservationId\) === asId\(slot\.reservationId\)/)
assert.match(source, /\['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'\]/)
assert.match(source, /deleteSucceeded/)
assert.match(source, /retainedSlot\?\.slotStatus === 'RESERVED_EMPTY'/)
assert.match(source, /不批量停用普通库位制造容量不足/)
})
test('approval freezes measurement and requires quantity weight and volume conservation', () => { test('approval freezes measurement and requires quantity weight and volume conservation', () => {
assert.match(source, /审核及合托完成后箱码尺寸与图片快照不可再覆盖/) assert.match(source, /审核及合托完成后箱码尺寸与图片快照不可再覆盖/)
assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/) assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/)
@@ -271,7 +271,10 @@ async function createConfirmedWeightEvidence(context, {
staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409', staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409',
`HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`, `HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`,
OPTIONS) OPTIONS)
confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) confirmedResults.push({
id: asId(created.id), palletId, netWeightKg, grossWeightKg,
version: Number(confirmed?.version),
})
} }
const workspaceResponse = await context.request('GET', const workspaceResponse = await context.request('GET',
@@ -292,7 +295,41 @@ async function createConfirmedWeightEvidence(context, {
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
`confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`,
OPTIONS) OPTIONS)
return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg, confirmedResults } : null
}
function verifyPalletLocationGuide(context, response, workspace, expectedWeightResultIds, name) {
const guide = workspace?.palletLocationGuide
const slots = Array.isArray(guide?.slots) ? guide.slots : []
const weights = Array.isArray(workspace?.weightResults) ? workspace.weightResults : []
const boundSlots = slots.filter((slot) => asId(slot?.boundWeightResultId))
const emptySlots = slots.filter((slot) => slot?.slotStatus === 'RESERVED_EMPTY')
const identityComplete = asId(guide?.blockId)
&& slots.length === Number(guide?.reservedSlotCount)
&& Number(guide?.actualPalletCount) === boundSlots.length
&& Number(guide?.reservedEmptySlotCount) === emptySlots.length
&& slots.every((slot) => Number.isInteger(Number(slot?.slotNo)) && Number(slot.slotNo) > 0
&& asId(slot?.reservationId) && asId(slot?.locationId)
&& String(slot?.locationCode || '').trim() && typeof slot?.sharedOverflow === 'boolean'
&& ['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'].includes(String(slot?.slotStatus || '')))
context.assert(MODULE, `${name}整票库位块计数与槽身份完整`, response, Boolean(identityComplete),
`blockId=${asId(guide?.blockId) || 'missing'}, reserved=${guide?.reservedSlotCount ?? 'missing'}, actual=${guide?.actualPalletCount ?? 'missing'}, empty=${guide?.reservedEmptySlotCount ?? 'missing'}, slots=${slots.length}`,
OPTIONS)
const bindingsStable = expectedWeightResultIds.every((resultId) => {
const row = weights.find((item) => asId(item?.id) === resultId)
const slot = slots.find((item) => asId(item?.boundWeightResultId) === resultId)
return row && slot && asId(row.blockId) === asId(guide?.blockId)
&& Number(row.slotNo) === Number(slot.slotNo)
&& asId(row.reservationId) === asId(slot.reservationId)
&& asId(row.locationId) === asId(slot.locationId)
&& String(row.locationCode || '') === String(slot.locationCode || '')
&& Boolean(row.sharedOverflow) === Boolean(slot.sharedOverflow)
})
context.assert(MODULE, `${name}称重图片按 resultId 显式绑定槽位`, response, bindingsStable,
`expectedResultIds=${expectedWeightResultIds.join('|') || 'none'}, boundResultIds=${boundSlots.map((slot) => asId(slot.boundWeightResultId)).join('|') || 'none'}`,
OPTIONS)
return { guide, slots, weights, identityComplete: Boolean(identityComplete), bindingsStable }
} }
function findIsolatedInbound(context) { function findIsolatedInbound(context) {
@@ -708,6 +745,62 @@ export async function run(context) {
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING', submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS) `processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
if (context.options?.verifyPalletLocationGuide) {
const guideResponse = await context.request('GET', measurementPath)
const guideWorkspace = context.apiSuccess(MODULE, '提交后读取整票库位块与称重图片槽绑定', guideResponse,
`inboundId=${inboundId}`, OPTIONS)
const beforeDelete = verifyPalletLocationGuide(context, guideResponse, guideWorkspace,
weightEvidence.confirmedResults.map((item) => item.id), '删除前')
const overflowSlots = beforeDelete.slots.filter((slot) => slot?.sharedOverflow === true)
context.addStep(MODULE, '正式数据 A0 共享溢出观测', guideResponse, true,
overflowSlots.length > 0
? `sharedOverflow=true, slots=${overflowSlots.map((slot) => `${slot.slotNo}:${slot.reservationId}:${slot.locationCode}`).join('|')}`
: '本票未触发 A0;为保护共享测试仓,不批量停用普通库位制造容量不足',
{ scenario: SCENARIO })
const deletedWeight = weightEvidence.confirmedResults[0]
const deleteResponse = await context.request('DELETE',
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${deletedWeight.id}?${query(tenantId)}&version=${deletedWeight.version}`)
const deleteSucceeded = deleteResponse.http === 200 && String(deleteResponse.body?.code) === '200'
context.assert(MODULE, '删除称重图片后保留原 reservation/location 并释放为空槽', deleteResponse,
deleteSucceeded,
`HTTP=${deleteResponse.http}, code=${deleteResponse.body?.code ?? 'missing'}, message=${deleteResponse.body?.message ?? 'missing'}, requestId=${deleteResponse.requestId || 'missing'}, traceId=${deleteResponse.traceId || 'missing'}`,
OPTIONS)
if (deleteSucceeded) {
const afterResponse = await context.request('GET', measurementPath)
const afterWorkspace = context.apiSuccess(MODULE, '删除后回查整票库位块', afterResponse,
`deletedWeightResultId=${deletedWeight.id}`, OPTIONS)
const afterDelete = verifyPalletLocationGuide(context, afterResponse, afterWorkspace,
weightEvidence.confirmedResults.slice(1).map((item) => item.id), '删除后')
const previousSlot = beforeDelete.slots.find((slot) => asId(slot?.boundWeightResultId) === deletedWeight.id)
const retainedSlot = afterDelete.slots.find((slot) =>
asId(slot?.reservationId) === asId(previousSlot?.reservationId)
&& asId(slot?.locationId) === asId(previousSlot?.locationId))
context.assert(MODULE, '删除只解绑图片且原槽变为 RESERVED_EMPTY', afterResponse,
Boolean(previousSlot) && retainedSlot?.slotStatus === 'RESERVED_EMPTY'
&& !asId(retainedSlot?.boundWeightResultId)
&& Number(afterDelete.guide?.reservedSlotCount) === Number(beforeDelete.guide?.reservedSlotCount),
`reservationId=${asId(retainedSlot?.reservationId) || 'missing'}, locationId=${asId(retainedSlot?.locationId) || 'missing'}, slotStatus=${retainedSlot?.slotStatus || 'missing'}`,
OPTIONS)
context.createdResources.inboundMeasurementLocationGuide = {
marker: context.marker, inboundId, tenantId, blockId: asId(afterDelete.guide?.blockId),
deletedWeightResultId: deletedWeight.id, deleteSucceeded: true,
sharedOverflowObserved: afterDelete.slots.some((slot) => slot?.sharedOverflow === true),
}
finishScenario(context, start)
return
}
context.createdResources.inboundMeasurementLocationGuide = {
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
deletedWeightResultId: deletedWeight.id, deleteSucceeded: false,
deleteCode: deleteResponse.body?.code || null,
deleteMessage: deleteResponse.body?.message || null,
deleteRequestId: deleteResponse.requestId || null,
deleteTraceId: deleteResponse.traceId || null,
sharedOverflowObserved: overflowSlots.length > 0,
}
}
const rejectedScanResponse = await multipartRequest(context, scanPath, { const rejectedScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19), boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
}) })
@@ -124,8 +124,10 @@ function inventorySignature(snapshot) {
inboundId: id(inbound?.inboundId), inboundId: id(inbound?.inboundId),
availablePackageCount: Number(inbound?.availablePackageCount), availablePackageCount: Number(inbound?.availablePackageCount),
resources: (inbound?.resources || []).map((resource) => ({ resources: (inbound?.resources || []).map((resource) => ({
reservationId: id(resource?.reservationId), occupancyId: id(resource?.occupancyId), blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
palletId: id(resource?.palletId), locationId: id(resource?.locationId), reservationId: id(resource?.reservationId), weightResultId: id(resource?.weightResultId),
palletSequence: Number(resource?.palletSequence), palletId: id(resource?.palletId),
locationId: id(resource?.locationId), slotStatus: resource?.slotStatus || null,
sharedOverflow: resource?.sharedOverflow === true, sharedOverflow: resource?.sharedOverflow === true,
availablePackageCount: Number(resource?.availablePackageCount), availablePackageCount: Number(resource?.availablePackageCount),
})), })),
@@ -622,8 +624,9 @@ export async function run(context) {
&& id(inventory?.warehouseId) === warehouseId && id(inventory?.warehouseId) === warehouseId
&& Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId) && (inventoryInbound?.resources || []).every((resource) => id(resource?.blockId)
&& id(resource?.occupancyId) && id(resource?.palletId) && id(resource?.locationId) && Number(resource?.slotNo) >= 1 && id(resource?.reservationId) && id(resource?.locationId)
&& ['RESERVED_EMPTY', 'BOUND'].includes(resource?.slotStatus)
&& Number(resource?.availablePackageCount) >= 0) && Number(resource?.availablePackageCount) >= 0)
&& Number(inventoryInbound?.availablePackageCount) === (inventoryInbound?.resources || []) && Number(inventoryInbound?.availablePackageCount) === (inventoryInbound?.resources || [])
.reduce((total, resource) => total + Number(resource?.availablePackageCount || 0), 0) .reduce((total, resource) => total + Number(resource?.availablePackageCount || 0), 0)