Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -46,13 +46,6 @@ public interface WarehousePalletLocationClient {
|
||||
ApiResponse<BlockResult> releaseBlock(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@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,
|
||||
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<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,
|
||||
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,
|
||||
String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
|
||||
List<InventoryResource> 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,
|
||||
|
||||
@@ -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<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
|
||||
.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<CartonLabelRow> 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<LoadingPickItemEntity> items) {
|
||||
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
|
||||
warehouseId, items, tenantByInbound(task.getId()));
|
||||
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
|
||||
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
|
||||
List<WarehousePalletLocationClient.OccupiedResource> current = occupiedResources(inboundId);
|
||||
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();
|
||||
List<InventoryResource> 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<WarehousePalletLocationClient.OccupiedResource> occupiedResources(long inboundId) {
|
||||
ApiResponse<List<WarehousePalletLocationClient.OccupiedResource>> 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<Long, Long> 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<Long, List<InventoryResource>> inventoryByInbound(long warehouseId,
|
||||
List<LoadingPickItemEntity> items,
|
||||
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);
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
+7
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}")
|
||||
|
||||
+14
-3
@@ -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'");
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -288,6 +288,7 @@ function usage() {
|
||||
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
|
||||
--wait-split-sequence <n> 单票全局序号,正整数,默认1
|
||||
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
|
||||
--verify-pallet-location-guide <true> 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
|
||||
--route-mode <mode> 线路选择:fixed 或 random,默认fixed
|
||||
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
|
||||
--manual-split-plan-id <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') || '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user