diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 2ba0cd98b..a282fbe0a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10450,8 +10450,22 @@ DELIMITER ; CALL assert_phase3_final_schema(); DROP PROCEDURE IF EXISTS assert_phase3_final_schema; --- PDA 部分拣货释放策略:NULL/AUTO 按比例释放,MANUAL + [] 表示人工明确不释放。 +-- PDA 整票库位块直接使用 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; DELIMITER $$ CREATE PROCEDURE phase3_pda_add_column( @@ -10471,14 +10485,6 @@ BEGIN END IF; END$$ 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( 'fl_inbound_pallet_allocation', 'block_id', "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', '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', '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', 'version', '乐观锁版本号'); 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; CREATE TABLE IF NOT EXISTS wh_location_block ( - id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - warehouse_id BIGINT UNSIGNED NOT NULL, source_service VARCHAR(40) NOT NULL, - business_id BIGINT UNSIGNED NOT NULL, inbound_code VARCHAR(40) NOT NULL, - slot_count INT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, - block_status VARCHAR(20) NOT NULL, expires_at DATETIME(3) NOT NULL, - last_operation_key VARCHAR(128) NULL, created_by BIGINT UNSIGNED NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT NOT NULL DEFAULT 0, + id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + source_service VARCHAR(40) NOT NULL COMMENT '来源服务', + business_id BIGINT UNSIGNED NOT NULL COMMENT '当前归属入库单业务ID', + inbound_code VARCHAR(40) NOT NULL COMMENT '当前归属入仓码', + slot_count INT UNSIGNED NOT NULL COMMENT '整票独占槽位数', + idempotency_key VARCHAR(128) NOT NULL COMMENT '预占幂等键', + 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 - (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), 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), @@ -15011,15 +15022,19 @@ CREATE TABLE IF NOT EXISTS wh_location_block ( ) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块'; CREATE TABLE IF NOT EXISTS wh_location_block_slot ( id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, block_id BIGINT UNSIGNED NOT NULL, - slot_no INT UNSIGNED NOT NULL, location_id BIGINT UNSIGNED NOT NULL, - location_code VARCHAR(50) NOT NULL, shared_overflow TINYINT(1) NOT NULL DEFAULT 0, - resource_status VARCHAR(20) NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - version INT NOT NULL DEFAULT 0, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0', + block_id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID', + slot_no INT UNSIGNED NOT NULL COMMENT '块内稳定槽位序号,从1开始', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照', + shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否落入同仓共享A0', + 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 - (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), 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), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java index fd92f964c..618d54e6c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java @@ -46,13 +46,6 @@ public interface WarehousePalletLocationClient { ApiResponse releaseBlock(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody BlockReleaseRequest request); - @GetMapping("/pallet-locations/occupied-resources") - ApiResponse> 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, String inboundCode, int requiredSlotCount, String idempotencyKey, long operatorId, LocalDateTime expiresAt) {} @@ -65,9 +58,6 @@ public interface WarehousePalletLocationClient { record BlockResource(long blockId, long reservationId, long locationId, String locationCode, int slotNo, boolean sharedOverflow, String resourceStatus) {} record BlockResult(long blockId, String blockStatus, List 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, BigDecimal capacityCbm, BigDecimal tareWeightKg) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 3a68085fa..bb7818725 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -92,6 +92,7 @@ public final class LoadingPickDtos { public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId, String orderNo, String inboundCode, String cargoName, List boxCodes, + List inventoryResources, Integer plannedPackageCount, Integer pickedPackageCount, String itemStatus, Integer version) { } @@ -131,9 +132,10 @@ public final class LoadingPickDtos { 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, - Integer availablePackageCount) { + String slotStatus, Integer availablePackageCount) { } public record InboundInventory(Long inboundId, Integer availablePackageCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index d837450b3..5546a0b33 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -1,9 +1,7 @@ package com.baiyue.firstleg.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; 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.PickRequest; 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.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; 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; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import jakarta.annotation.Resource; -import feign.FeignException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionTemplate; @@ -80,8 +78,6 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; @Service public class LoadingPickService { 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 private LoadingPickRepository repository; @Resource @@ -107,9 +103,7 @@ public class LoadingPickService { @Resource private PdaPickSettlementService settlementService; @Resource - private WarehousePalletLocationClient warehouse; - @Value("${baiyue.security.internal-service-token:test-internal-service-token}") - private String internalToken; + private InboundPalletAllocationRepository palletAllocations; /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional @@ -908,14 +902,19 @@ public class LoadingPickService { ? repository.items(task.getId()) : repository.items(task.getId(), warehouseId); Map> cartonsByItem = repository.cartonLabels(task.getId()).stream() .collect(Collectors.groupingBy(CartonLabelRow::getPickItemId)); + Map tenantByInbound = tenantByInbound(task.getId()); + Map> inventoryByInbound = inventoryByInbound( + booking.getWarehouseId(), source, tenantByInbound); List items = source.stream().map(item -> { List cartons = cartonsByItem.getOrDefault(item.getId(), List.of()); 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.getInboundDetailId(), first == null ? null : first.getOrderNo(), 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()); }).toList(); LoadingPickAssignmentEntity assigned = repository.assignment(task.getId()); @@ -1038,17 +1037,11 @@ public class LoadingPickService { private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId, List items) { + Map> inventoryByInbound = inventoryByInbound( + warehouseId, items, tenantByInbound(task.getId())); List inbounds = items.stream().map(LoadingPickItemEntity::getInboundId) .filter(Objects::nonNull).distinct().sorted().map(inboundId -> { - List current = occupiedResources(inboundId); - List 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(); + List resources = inventoryByInbound.getOrDefault(inboundId, List.of()); int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum(); return new InboundInventory(inboundId, available, resources); }).toList(); @@ -1059,20 +1052,54 @@ public class LoadingPickService { LocalDateTime.now(), token, inbounds); } - private List occupiedResources(long inboundId) { - ApiResponse> response; - try { - response = warehouse.occupiedResources(internalToken, PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID, - SOURCE_SERVICE, inboundId); - } catch (FeignException exception) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储实时库存查询失败,请刷新后重试"); + private Map tenantByInbound(long taskId) { + return repository.inboundOwners(taskId).stream().collect(Collectors.toMap( + LoadingPickItemMapper.InboundOwner::inboundId, + LoadingPickItemMapper.InboundOwner::tenantId, + (left, right) -> { + if (!Objects.equals(left, right)) throw conflict("PDA任务入库单客户归属冲突"); + return left; + })); + } + + private Map> inventoryByInbound(long warehouseId, + List items, + Map tenantByInbound) { + Map> 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 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); } - if (response == null || !response.successful() || response.data() == null) { - ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()); - throw new BizException(code, response == null || response.message() == null - ? "仓储实时库存查询失败,请刷新后重试" : response.message()); + return result; + } + + private static InventoryResource inventoryResource(InboundPalletAllocationEntity resource) { + if (resource.getBlockId() == null || resource.getSlotNo() == null + || resource.getReservationId() == null || resource.getLocationId() == null) { + throw conflict("入库单库位块快照不完整,请重新确认测量入库"); } - return response.data(); + 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, @@ -1082,8 +1109,9 @@ public class LoadingPickService { for (InboundInventory inbound : inbounds) { canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount()); for (InventoryResource resource : inbound.resources()) { - canonical.append('|').append(resource.reservationId()).append('|').append(resource.occupancyId()) - .append('|').append(resource.locationId()).append('|').append(resource.availablePackageCount()); + canonical.append('|').append(resource.blockId()).append('|').append(resource.slotNo()) + .append('|').append(resource.reservationId()).append('|').append(resource.locationId()) + .append('|').append(resource.availablePackageCount()); } } return hash(canonical.toString()); diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index a6828b869..909e5e753 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -294,7 +294,7 @@ components: items: {$ref: '#/components/schemas/PickItem'} PickItem: 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: id: {type: integer, format: int64} loadingPlanItemId: {type: integer, format: int64} @@ -308,6 +308,10 @@ components: type: array description: Stable carton label codes; one successful scan represents one carton 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} pickedPackageCount: {type: integer, minimum: 0} itemStatus: {type: string, enum: [PENDING, PICKING, PICKED, PARTIAL, SKIPPED]} @@ -427,12 +431,16 @@ components: items: {$ref: '#/components/schemas/InventoryResource'} InventoryResource: type: object - required: [reservationId, occupancyId, palletId, locationId, sharedOverflow, availablePackageCount] + required: [blockId, slotNo, reservationId, locationId, sharedOverflow, slotStatus, availablePackageCount] properties: + blockId: {type: integer, format: int64} + slotNo: {type: integer, minimum: 1} reservationId: {type: integer, format: int64} - occupancyId: {type: integer, format: int64} - palletId: {type: integer, format: int64} + weightResultId: {type: integer, format: int64, nullable: true} + palletSequence: {type: integer, minimum: 1, nullable: true} + palletId: {type: integer, format: int64, nullable: true} locationId: {type: integer, format: int64} locationCode: {type: string, nullable: true} sharedOverflow: {type: boolean} - availablePackageCount: {type: integer, minimum: 1} + slotStatus: {type: string, enum: [RESERVED_EMPTY, BOUND]} + availablePackageCount: {type: integer, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index b7b9e7141..7401e6d91 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -52,6 +52,8 @@ class PdaConvergenceOpenApiContractTest { String batchItemRequest = yaml.substring( yaml.indexOf(" BatchItemRequest:"), yaml.indexOf(" ReviewRequest:")); + String inventoryResource = yaml.substring( + yaml.indexOf(" InventoryResource:")); assertThat(taskLookup) .contains("name: cabinetNo", "name: loadingPlanId") .doesNotContain("name: containerNo"); @@ -64,5 +66,10 @@ class PdaConvergenceOpenApiContractTest { .doesNotContain("containerNo:"); assertThat(batchItemRequest) .doesNotContain("releaseMode:", "reservationIds:", "locationIds:"); + assertThat(inventoryResource) + .contains("blockId:", "slotNo:", "reservationId:", "weightResultId:", "slotStatus:") + .doesNotContain("occupancyId:"); + assertThat(yaml).contains("inventoryResources:", + "never projected from legacy occupancy rows"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 06477ebdf..39050982e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -92,7 +92,7 @@ class LoadingPickItemMapperContractTest { void pdaWritesAndReadsRequireWarehouseScope() throws Exception { Method list = LoadingPickItemMapper.class.getDeclaredMethod("selectTaskItemsWithAccount", long.class, long.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]) .contains("booking.warehouse_id = #{warehouseId}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index a7703c107..08e2d4f21 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -157,11 +157,22 @@ class PdaSplitSettlementSqlContractTest { .contains("allocation_status = 'CONFIRMED'") .doesNotContain("THEN 'RELEASED'"); - String release = InboundPalletAllocationMapper.class.getMethod("markReleased", + String release = InboundPalletAllocationMapper.class.getMethod("markBlockReleased", long.class, long.class, long.class) .getAnnotation(Update.class).value()[0]; assertThat(release) - .contains("allocation_status = 'RELEASED'") - .contains("allocation_status = 'CONFIRMED'"); + .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'"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index d8c5a5675..24c823823 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -252,6 +252,21 @@ class Phase3FinalUnifiedSqlContractTest { .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 void legacySplitIndexesAreOnlyDroppedAndNeverRecreated() throws Exception { String sql = normalise(Files.readString(resolveUnified())); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 3f13586fd..9d8fe12b1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -11,8 +11,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; -import com.baiyue.firstleg.client.WarehousePalletLocationClient; -import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; 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.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; @@ -100,7 +100,7 @@ class LoadingPickServiceTest { @MockBean private PdaPickSettlementService settlementService; @MockBean - private WarehousePalletLocationClient warehouse; + private InboundPalletAllocationRepository palletAllocations; @Resource private LoadingPickService service; @@ -122,6 +122,10 @@ class LoadingPickServiceTest { when(completionCommands.insert(any())).thenReturn(1); when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); 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 @@ -437,6 +441,7 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(false); when(repository.inboundOwners(20L)).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2))); when(repository.inbound(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); var result = service.task(20L, 101L); @@ -449,6 +454,14 @@ class LoadingPickServiceTest { assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); 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); } @@ -458,7 +471,9 @@ class LoadingPickServiceTest { when(repository.task(20L)).thenReturn(task); when(repository.items(20L, 101L)).thenReturn(List.of(item())); 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")) .when(completionCommands).latest(20L); @@ -1150,10 +1165,17 @@ class LoadingPickServiceTest { when(repository.allTaskItemsResolved(20L)).thenReturn(true); when(repository.touchTask(20L, 0)).thenReturn(1); when(repository.allPlanItemsPicked(10L)).thenReturn(true); - when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( - ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( - 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); - String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L), + new LoadingPickItemMapper.InboundOwner(1L, 51L))); + 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( new BatchItemRequest(30L, "PICK", 2, null, 0, null), new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton"))); @@ -1191,9 +1213,10 @@ class LoadingPickServiceTest { when(repository.touchTask(20L, 0)).thenReturn(1); when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1); when(smartLoadingRepository.operation("PDA-START-20")).thenReturn(existingStart); - when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( - ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( - 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 100)))); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 100))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of( new BatchItemRequest(30L, "PICK", 100, null, 0, null))); @@ -1242,9 +1265,10 @@ class LoadingPickServiceTest { when(repository.assignment(20L)).thenReturn(assignment()); when(repository.items(20L)).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( - ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( - 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + 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( 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.allTaskItemsResolved(20L)).thenReturn(true); when(repository.touchTask(20L, 0)).thenReturn(1); - when(warehouse.occupiedResources(any(), any(Long.class), any(), any(Long.class))).thenReturn( - ApiResponse.success(List.of(new WarehousePalletLocationClient.OccupiedResource( - 71L, 72L, 0, 101L, 73L, 74L, "A-01", false, 2)))); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(1L, 50L))); + when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation -> + List.of(allocation(invocation.getArgument(1), 2))); String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken(); BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null); @@ -1405,6 +1430,27 @@ class LoadingPickServiceTest { 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() { LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity(); assignment.setPickTaskId(20L); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 314510b89..beaef6a74 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -288,6 +288,7 @@ function usage() { --wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换 --wait-split-sequence 单票全局序号,正整数,默认1 --wait-split-random-value 单票随机选择值,正整数,默认使用全局序号 + --verify-pallet-location-guide 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY --route-mode 线路选择:fixed 或 random,默认fixed --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 @@ -348,6 +349,7 @@ export function parseArguments(argv) { waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitSequence: Number(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', quiet: values.get('quiet') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 710eeaeca..6f19b7822 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 ``` +整票库位块专项可在只跑 `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 链,也不得与完整链同时解释为同一终态验收。 ```powershell diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index d4869d323..6ec27ed38 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -69,6 +69,16 @@ test('demo package keeps pallet pages bounded and historical snapshots read-only 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', () => { assert.match(source, /审核及合托完成后箱码尺寸与图片快照不可再覆盖/) assert.match(measurementTests, /approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals/) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 6fc300bb6..9d5686587 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -271,7 +271,10 @@ async function createConfirmedWeightEvidence(context, { staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409', `HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`, 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', @@ -292,7 +295,41 @@ async function createConfirmedWeightEvidence(context, { context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, 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) { @@ -708,6 +745,62 @@ export async function run(context) { submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING', `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, { boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19), }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 907422da5..b11e81398 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -124,8 +124,10 @@ function inventorySignature(snapshot) { inboundId: id(inbound?.inboundId), availablePackageCount: Number(inbound?.availablePackageCount), resources: (inbound?.resources || []).map((resource) => ({ - reservationId: id(resource?.reservationId), occupancyId: id(resource?.occupancyId), - palletId: id(resource?.palletId), locationId: id(resource?.locationId), + blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo), + 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, availablePackageCount: Number(resource?.availablePackageCount), })), @@ -622,8 +624,9 @@ export async function run(context) { && id(inventory?.warehouseId) === warehouseId && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 - && (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId) - && id(resource?.occupancyId) && id(resource?.palletId) && id(resource?.locationId) + && (inventoryInbound?.resources || []).every((resource) => id(resource?.blockId) + && Number(resource?.slotNo) >= 1 && id(resource?.reservationId) && id(resource?.locationId) + && ['RESERVED_EMPTY', 'BOUND'].includes(resource?.slotStatus) && Number(resource?.availablePackageCount) >= 0) && Number(inventoryInbound?.availablePackageCount) === (inventoryInbound?.resources || []) .reduce((total, resource) => total + Number(resource?.availablePackageCount || 0), 0)